Initial commit: Project structure and layout

This commit is contained in:
2026-04-22 01:45:29 +08:00
commit 481555c257
67 changed files with 17248 additions and 0 deletions

41
client/src/App.vue Normal file
View File

@@ -0,0 +1,41 @@
<template>
<div class="app-layout">
<!-- 顶部工具栏 -->
<TopToolbar />
<!-- 主体内容区 -->
<div class="main-content">
<!-- 左侧面板 -->
<LeftPanel />
<!-- 中间聊天区 -->
<CenterChat />
<!-- 右侧工具面板 -->
<RightPanel />
</div>
</div>
</template>
<script setup lang="ts">
import TopToolbar from './components/layout/TopToolbar.vue';
import LeftPanel from './components/layout/LeftPanel.vue';
import CenterChat from './components/layout/CenterChat.vue';
import RightPanel from './components/layout/RightPanel.vue';
</script>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

View File

@@ -0,0 +1,72 @@
<template>
<div :class="['message-item', message.senderRole]">
<div class="message-header">
<span class="sender-name">{{ message.senderName }}</span>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="message-content">{{ message.mes }}</div>
</div>
</template>
<script setup lang="ts">
interface Message {
id: string;
senderName: string;
senderRole: 'user' | 'assistant' | 'system';
mes: string;
timestamp: Date;
}
defineProps<{
message: Message;
}>();
function formatTime(date: Date) {
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<style scoped>
.message-item {
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
background: #ffffff;
border: 1px solid #e0e0e0;
}
.message-item.user {
background: #e3f2fd;
border-color: #bbdefb;
margin-left: 20%;
}
.message-item.assistant {
background: #f1f8e9;
border-color: #dcedc8;
margin-right: 20%;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #7f8c8d;
}
.sender-name {
font-weight: 600;
color: #2c3e50;
}
.message-content {
line-height: 1.6;
white-space: pre-wrap;
font-size: 14px;
color: #2c3e50;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="api-config-view">
<h3>🔌 API 配置</h3>
<p>API 配置管理界面</p>
</div>
</template>
<style scoped>
.api-config-view {
padding: 8px;
}
h3 {
font-size: 14px;
color: #2c3e50;
margin: 0 0 12px 0;
}
p {
font-size: 13px;
color: #7f8c8d;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="gallery-view">
<h3>🖼 画廊</h3>
<p>LLM 生成的图片将显示在这里</p>
</div>
</template>
<style scoped>
.gallery-view {
padding: 8px;
}
h3 {
font-size: 14px;
color: #2c3e50;
margin: 0 0 12px 0;
}
p {
font-size: 13px;
color: #7f8c8d;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="preset-view">
<h3> 预设管理</h3>
<p>预设配置和条目展示</p>
</div>
</template>
<style scoped>
.preset-view {
padding: 8px;
}
h3 {
font-size: 14px;
color: #2c3e50;
margin: 0 0 12px 0;
}
p {
font-size: 13px;
color: #7f8c8d;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="world-book-view">
<h3>📚 世界书管理</h3>
<p>全局世界书激活和条目管理</p>
</div>
</template>
<style scoped>
.world-book-view {
padding: 8px;
}
h3 {
font-size: 14px;
color: #2c3e50;
margin: 0 0 12px 0;
}
p {
font-size: 13px;
color: #7f8c8d;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="dice-view">
<h3>🎲 骰子工具</h3>
<p>纯前端骰子功能</p>
</div>
</template>
<style scoped>
.dice-view {
padding: 8px;
}
h3 {
font-size: 14px;
color: #2c3e50;
margin: 0 0 12px 0;
}
p {
font-size: 13px;
color: #7f8c8d;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="rag-view">
<h3>🔍 RAG 检索</h3>
<p>RAG 知识检索功能</p>
</div>
</template>
<style scoped>
.rag-view {
padding: 8px;
}
h3 {
font-size: 14px;
color: #2c3e50;
margin: 0 0 12px 0;
}
p {
font-size: 13px;
color: #7f8c8d;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="world-book-call-view">
<h3>📚 世界书调用</h3>
<p>世界书条目调用记录</p>
</div>
</template>
<style scoped>
.world-book-call-view {
padding: 8px;
}
h3 {
font-size: 14px;
color: #2c3e50;
margin: 0 0 12px 0;
}
p {
font-size: 13px;
color: #7f8c8d;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<main class="center-chat">
<!-- 消息列表 -->
<div class="message-list">
<CenterMessageItem
v-for="msg in messages"
:key="msg.id"
:message="msg"
/>
</div>
<!-- 输入区域 -->
<div class="input-area">
<!-- 选项框 -->
<div class="input-options">
<select v-model="sendOptions.role" class="option-select">
<option value="user">用户</option>
<option value="assistant">AI</option>
</select>
</div>
<!-- 输入框 -->
<textarea
v-model="inputMessage"
class="message-input"
placeholder="输入消息..."
@keydown.enter.ctrl="sendMessage"
></textarea>
<!-- 发送/停止按钮 -->
<button
:class="['send-btn', { sending: isSending }]"
@click="isSending ? stopGeneration() : sendMessage()"
>
{{ isSending ? '⏹ 停止' : '📤 发送' }}
</button>
</div>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import CenterMessageItem from '../features/center/MessageItem.vue';
interface Message {
id: string;
senderName: string;
senderRole: 'user' | 'assistant' | 'system';
mes: string;
timestamp: Date;
}
const messages = ref<Message[]>([
{
id: '1',
senderName: 'Assistant',
senderRole: 'assistant',
mes: '你好!有什么可以帮助你的吗?',
timestamp: new Date(),
},
]);
const inputMessage = ref('');
const isSending = ref(false);
const sendOptions = ref({
role: 'user' as 'user' | 'assistant',
});
function sendMessage() {
if (!inputMessage.value.trim()) return;
const newMessage: Message = {
id: Date.now().toString(),
senderName: sendOptions.value.role === 'user' ? 'User' : 'Assistant',
senderRole: sendOptions.value.role,
mes: inputMessage.value,
timestamp: new Date(),
};
messages.value.push(newMessage);
inputMessage.value = '';
isSending.value = true;
// 模拟 AI 回复
setTimeout(() => {
isSending.value = false;
}, 2000);
}
function stopGeneration() {
isSending.value = false;
}
</script>
<style scoped>
.center-chat {
display: flex;
flex-direction: column;
flex: 1;
background: #ffffff;
overflow: hidden;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.input-area {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 16px;
background: #f8f9fa;
border-top: 2px solid #dee2e6;
flex-shrink: 0;
}
.input-options {
flex-shrink: 0;
}
.option-select {
padding: 8px;
border: 1px solid #ced4da;
border-radius: 4px;
background: white;
font-size: 13px;
}
.message-input {
flex: 1;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
resize: none;
font-size: 14px;
min-height: 60px;
max-height: 200px;
font-family: inherit;
}
.message-input:focus {
outline: none;
border-color: #3498db;
}
.send-btn {
padding: 12px 20px;
border: none;
border-radius: 4px;
background: #3498db;
color: white;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
flex-shrink: 0;
}
.send-btn:hover {
background: #2980b9;
}
.send-btn.sending {
background: #e74c3c;
}
.send-btn.sending:hover {
background: #c0392b;
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<aside class="left-panel">
<!-- 左侧-顶部分页标签 -->
<div class="panel-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['tab-btn', { active: currentTab === tab.id }]"
@click="currentTab = tab.id"
>
{{ tab.icon }} {{ tab.label }}
</button>
</div>
<!-- 左侧-内容区域 -->
<div class="panel-content">
<LeftGalleryView v-if="currentTab === 'gallery'" />
<LeftApiConfigView v-if="currentTab === 'api'" />
<LeftPresetView v-if="currentTab === 'preset'" />
<LeftWorldBookView v-if="currentTab === 'worldbook'" />
</div>
</aside>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import LeftGalleryView from '../features/left/GalleryView.vue';
import LeftApiConfigView from '../features/left/ApiConfigView.vue';
import LeftPresetView from '../features/left/PresetView.vue';
import LeftWorldBookView from '../features/left/WorldBookView.vue';
const currentTab = ref('gallery');
const tabs = [
{ id: 'gallery', label: '画廊', icon: '🖼️' },
{ id: 'api', label: 'API', icon: '🔌' },
{ id: 'preset', label: '预设', icon: '⚙️' },
{ id: 'worldbook', label: '世界书', icon: '📚' },
];
</script>
<style scoped>
.left-panel {
display: flex;
flex-direction: column;
width: 25%;
min-width: 300px;
max-width: 400px;
background: #ffffff;
border-right: 1px solid #e0e0e0;
flex-shrink: 0;
}
.panel-tabs {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
padding: 6px 8px;
gap: 4px;
flex-shrink: 0;
}
.tab-btn {
flex: 1;
padding: 8px 6px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #7f8c8d;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.tab-btn:hover {
background: #ffffff;
border-color: #e0e0e0;
color: #2c3e50;
}
.tab-btn.active {
background: #ffffff;
border-color: #3498db;
color: #3498db;
box-shadow: 0 1px 3px rgba(52, 152, 219, 0.1);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 16px;
background: #fafafa;
}
</style>

View File

@@ -0,0 +1,267 @@
<template>
<aside class="right-panel">
<!-- 右侧-顶部分页标签 -->
<div class="panel-tabs">
<button
v-for="tool in availableToolsList"
:key="tool.id"
:class="['tab-btn', { active: isToolActive(tool.id) }]"
@click="selectTool(tool.id)"
>
{{ tool.icon }} {{ tool.shortLabel }}
</button>
</div>
<!-- 右侧-上部分区最新使用的工具 -->
<div class="panel-section top-section">
<div class="section-header">
<span class="section-title">{{ recentTools[0]?.label || '未选择工具' }}</span>
<button class="collapse-btn" @click="toggleTop" title="折叠/展开">
{{ isTopCollapsed ? '▼' : '▲' }}
</button>
</div>
<div v-show="!isTopCollapsed" class="section-content">
<component :is="recentTools[0]?.component" v-if="recentTools[0]" />
<EmptyState v-else message="点击上方标签选择工具" />
</div>
</div>
<!-- 右侧-下部分区次新使用的工具 -->
<div class="panel-section bottom-section">
<div class="section-header">
<span class="section-title">{{ recentTools[1]?.label || '未选择工具' }}</span>
<button class="collapse-btn" @click="toggleBottom" title="折叠/展开">
{{ isBottomCollapsed ? '▼' : '▲' }}
</button>
</div>
<div v-show="!isBottomCollapsed" class="section-content">
<component :is="recentTools[1]?.component" v-if="recentTools[1]" />
<EmptyState v-else message="点击上方标签选择工具" />
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import RightToolDiceView from '../features/right/DiceView.vue';
import RightToolRagView from '../features/right/RagView.vue';
import RightToolWorldBookCallView from '../features/right/WorldBookCallView.vue';
import RightToolDynamicTableView from '../features/right/DynamicTableView.vue';
import EmptyState from '../shared/EmptyState.vue';
interface ToolItem {
id: string;
label: string;
shortLabel: string;
icon: string;
component: any;
}
const isTopCollapsed = ref(false);
const isBottomCollapsed = ref(false);
const recentTools = ref<ToolItem[]>([]);
// 所有可用工具
const availableTools: Record<string, ToolItem> = {
dice: {
id: 'dice',
label: '🎲 骰子工具',
shortLabel: '骰子',
icon: '🎲',
component: RightToolDiceView,
},
rag: {
id: 'rag',
label: '🔍 RAG 检索',
shortLabel: 'RAG',
icon: '🔍',
component: RightToolRagView,
},
worldbook: {
id: 'worldbook',
label: '📚 世界书调用',
shortLabel: '世界书',
icon: '📚',
component: RightToolWorldBookCallView,
},
table: {
id: 'table',
label: '📊 动态表格',
shortLabel: '表格',
icon: '📊',
component: RightToolDynamicTableView,
},
};
// 获取工具列表(用于渲染标签)
const availableToolsList = Object.values(availableTools);
// 判断工具是否在当前显示的两个中
function isToolActive(toolId: string): boolean {
return recentTools.value.some((tool) => tool.id === toolId);
}
// 选择工具(添加到最近使用队列)
function selectTool(toolId: string) {
const tool = availableTools[toolId];
if (!tool) return;
// 移除已存在的相同工具
recentTools.value = recentTools.value.filter((t) => t.id !== toolId);
// 添加到最前面
recentTools.value.unshift(tool);
// 只保留最近两个
if (recentTools.value.length > 2) {
recentTools.value = recentTools.value.slice(0, 2);
}
}
function toggleTop() {
isTopCollapsed.value = !isTopCollapsed.value;
}
function toggleBottom() {
isBottomCollapsed.value = !isBottomCollapsed.value;
}
// 暴露方法供外部调用
defineExpose({
selectTool,
});
// 初始化默认工具
selectTool('dice');
selectTool('worldbook');
</script>
<style scoped>
.right-panel {
display: flex;
flex-direction: column;
width: 25%;
min-width: 300px;
max-width: 400px;
background: #ffffff;
border-left: 1px solid #e0e0e0;
flex-shrink: 0;
}
.panel-tabs {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
padding: 6px 8px;
gap: 4px;
flex-shrink: 0;
overflow-x: auto;
}
.tab-btn {
flex: 1;
min-width: 60px;
padding: 8px 6px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #7f8c8d;
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.tab-btn:hover {
background: #ffffff;
border-color: #e0e0e0;
color: #2c3e50;
}
.tab-btn.active {
background: #ffffff;
border-color: #3498db;
color: #3498db;
box-shadow: 0 1px 3px rgba(52, 152, 219, 0.1);
}
.panel-section {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
border-bottom: 1px solid #e0e0e0;
}
.panel-section:last-child {
border-bottom: none;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #ffffff;
border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: #2c3e50;
}
.collapse-btn {
padding: 4px 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
color: #7f8c8d;
transition: color 0.2s;
border-radius: 3px;
}
.collapse-btn:hover {
color: #2c3e50;
background: #f8f9fa;
}
.section-content {
flex: 1;
overflow-y: auto;
padding: 12px;
background: #fafafa;
}
/* 滚动条样式 */
.panel-tabs::-webkit-scrollbar,
.section-content::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.panel-tabs::-webkit-scrollbar-track,
.section-content::-webkit-scrollbar-track {
background: transparent;
}
.panel-tabs::-webkit-scrollbar-thumb,
.section-content::-webkit-scrollbar-thumb {
background: #d0d0d0;
border-radius: 3px;
}
.panel-tabs::-webkit-scrollbar-thumb:hover,
.section-content::-webkit-scrollbar-thumb:hover {
background: #b0b0b0;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<div class="right-toolbar">
<span style="color: #bdc3c7; font-size: 12px;">工具栏</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
rightPanelRef?: any;
}>();
</script>
<style scoped>
.right-toolbar {
display: flex;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<header class="top-toolbar">
<div class="toolbar-left">
<!-- API 配置快速切换 -->
<div class="toolbar-item">
<span class="label">API:</span>
<select v-model="currentApi" class="toolbar-select">
<option value="primary"> API</option>
<option value="secondary"> API</option>
</select>
</div>
</div>
<div class="toolbar-center">
<!-- 当前用户角色 -->
<div class="toolbar-item">
<span class="label">用户:</span>
<span class="value">{{ currentUser }}</span>
</div>
<!-- 当前 AI 角色 -->
<div class="toolbar-item">
<span class="label">AI:</span>
<span class="value">{{ currentAICharacter }}</span>
</div>
<!-- 激活的全局世界书 -->
<div class="toolbar-item">
<span class="label">世界书:</span>
<span class="value">{{ activeWorldBooks.length }} 个激活</span>
</div>
</div>
<div class="toolbar-right">
<!-- 设置按钮 -->
<button class="toolbar-btn" @click="openSettings">
设置
</button>
<!-- 拓展按钮 -->
<button class="toolbar-btn" @click="openExtensions">
🧩 拓展
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const currentApi = ref('primary');
const currentUser = ref('User');
const currentAICharacter = ref('Assistant');
const activeWorldBooks = ref(['奇幻世界', '现代都市']);
function openSettings() {
console.log('Open settings');
}
function openExtensions() {
console.log('Open extensions');
}
</script>
<style scoped>
.top-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #2c3e50;
color: white;
border-bottom: 2px solid #34495e;
height: 50px;
flex-shrink: 0;
}
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
gap: 16px;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 8px;
}
.label {
font-size: 12px;
opacity: 0.8;
}
.value {
font-size: 14px;
font-weight: 500;
}
.toolbar-select {
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #34495e;
background: #34495e;
color: white;
font-size: 13px;
}
.toolbar-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: #3498db;
color: white;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.toolbar-btn:hover {
background: #2980b9;
}
</style>

View File

@@ -0,0 +1,51 @@
/**
* useApi Composable
* Provides API client with error handling and loading states
*/
import { ref } from 'vue';
import type { Ref } from 'vue';
interface UseApiOptions<T> {
immediate?: boolean;
onError?: (error: Error) => void;
onSuccess?: (data: T) => void;
}
export function useApi<T>(
apiCall: () => Promise<T>,
options: UseApiOptions<T> = {}
) {
const data: Ref<T | null> = ref(null);
const error: Ref<Error | null> = ref(null);
const loading: Ref<boolean> = ref(false);
const execute = async () => {
loading.value = true;
error.value = null;
try {
const result = await apiCall();
data.value = result;
options.onSuccess?.(result);
return result;
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err));
options.onError?.(error.value);
throw error.value;
} finally {
loading.value = false;
}
};
if (options.immediate) {
execute();
}
return {
data,
error,
loading,
execute,
};
}

14
client/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')