feat(web): bots page

This commit is contained in:
Acbox
2026-02-10 18:59:18 +08:00
parent 4d265b8f24
commit 169d9a35af
21 changed files with 1814 additions and 11 deletions
+4
View File
@@ -2,6 +2,10 @@
import { RouterView } from 'vue-router'
import { Toaster } from '@memoh/ui'
import 'vue-sonner/style.css'
import { useSettingsStore } from '@/store/settings'
// 初始化设置(主题、语言),确保在任何页面进入时都已应用
useSettingsStore()
</script>
<template>
@@ -80,10 +80,15 @@ const sidebarInfo = computed(() => [
name: 'chat',
icon: ['far', 'comments'],
},
{
title: t('sidebar.bots'),
name: 'bots',
icon: ['fas', 'robot'],
},
{
title: t('sidebar.models'),
name: 'models',
icon: ['fas', 'robot'],
icon: ['fas', 'cubes'],
},
{
title: t('sidebar.settings'),
@@ -71,10 +71,15 @@ import { computed } from 'vue'
const route = useRoute()
const curBreadcrumb = computed(() => {
return route.matched.map(routeItem => ({
path: routeItem.path,
breadcrumb: routeItem.meta['breadcrumb']
}))
return route.matched
.filter(routeItem => routeItem.meta['breadcrumb'])
.map(routeItem => {
const raw = routeItem.meta['breadcrumb']
return {
path: routeItem.path,
breadcrumb: typeof raw === 'function' ? raw(route) : raw,
}
})
})
</script>
@@ -0,0 +1,46 @@
import { fetchApi } from '@/utils/request'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import type { Ref } from 'vue'
// ---- Types ----
export interface BotSettings {
chat_model_id: string
memory_model_id: string
embedding_model_id: string
max_context_load_time: number
language: string
allow_guest: boolean
}
export interface UpsertBotSettingsRequest {
chat_model_id?: string
memory_model_id?: string
embedding_model_id?: string
max_context_load_time?: number
language?: string
allow_guest?: boolean
}
// ---- Query ----
export function useBotSettings(botId: Ref<string>) {
return useQuery({
key: () => ['bot-settings', botId.value],
query: () => fetchApi<BotSettings>(`/bots/${botId.value}/settings`),
enabled: () => !!botId.value,
})
}
// ---- Mutation ----
export function useUpdateBotSettings(botId: Ref<string>) {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: UpsertBotSettingsRequest) => fetchApi<BotSettings>(
`/bots/${botId.value}/settings`,
{ method: 'PUT', body: data },
),
onSettled: () => queryCache.invalidateQueries({ key: ['bot-settings', botId.value] }),
})
}
@@ -0,0 +1,99 @@
import { fetchApi } from '@/utils/request'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import type { Ref } from 'vue'
// ---- Types ----
export interface BotInfo {
id: string
display_name: string
avatar_url: string
type: string
is_active: boolean
owner_user_id: string
metadata: Record<string, unknown>
created_at: string
updated_at: string
}
export interface ListBotsResponse {
items: BotInfo[]
}
export interface CreateBotRequest {
display_name: string
avatar_url?: string
type?: string
is_active?: boolean
metadata?: Record<string, unknown>
}
export interface UpdateBotRequest {
display_name?: string
avatar_url?: string
is_active?: boolean
metadata?: Record<string, unknown>
}
// ---- Query: 获取 Bot 列表 ----
export function useBotList() {
const queryCache = useQueryCache()
const query = useQuery({
key: ['bots'],
query: async (): Promise<BotInfo[]> => {
const res = await fetchApi<ListBotsResponse>('/bots')
return res.items ?? []
},
})
return {
...query,
invalidate: () => queryCache.invalidateQueries({ key: ['bots'] }),
}
}
// ---- Query: 获取单个 Bot 详情 ----
export function useBotDetail(botId: Ref<string>) {
return useQuery({
key: () => ['bot', botId.value],
query: () => fetchApi<BotInfo>(`/bots/${botId.value}`),
enabled: () => !!botId.value,
})
}
// ---- Mutations ----
export function useCreateBot() {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: CreateBotRequest) => fetchApi<BotInfo>('/bots', {
method: 'POST',
body: data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['bots'] }),
})
}
export function useDeleteBot() {
const queryCache = useQueryCache()
return useMutation({
mutation: (botId: string) => fetchApi(`/bots/${botId}`, {
method: 'DELETE',
}),
onSettled: () => queryCache.invalidateQueries({ key: ['bots'] }),
})
}
export function useUpdateBot() {
const queryCache = useQueryCache()
return useMutation({
mutation: ({ id, ...data }: UpdateBotRequest & { id: string }) => fetchApi<BotInfo>(`/bots/${id}`, {
method: 'PUT',
body: data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['bots'] }),
})
}
@@ -0,0 +1,143 @@
import { fetchApi, ApiError } from '@/utils/request'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import type { Ref } from 'vue'
// ---- Types ----
export interface FieldSchema {
title: string
description?: string
type: 'string' | 'secret' | 'bool' | 'number' | 'enum'
required?: boolean
enum?: string[]
example?: unknown
}
export interface ConfigSchema {
version: number
fields: Record<string, FieldSchema>
}
export interface ChannelCapabilities {
text: boolean
markdown: boolean
rich_text: boolean
attachments: boolean
media: boolean
reactions: boolean
buttons: boolean
reply: boolean
threads: boolean
streaming: boolean
polls: boolean
edit: boolean
unsend: boolean
native_commands: boolean
block_streaming: boolean
chat_types?: string[]
}
export interface ChannelMeta {
type: string
display_name: string
configless: boolean
capabilities: ChannelCapabilities
config_schema: ConfigSchema
user_config_schema: ConfigSchema
target_spec: { format: string; description: string }
}
export interface ChannelConfig {
id: string
botID: string
channelType: string
credentials: Record<string, unknown>
externalIdentity: string
selfIdentity: Record<string, unknown>
routing: Record<string, unknown>
status: string
verifiedAt: string
createdAt: string
updatedAt: string
}
export interface UpsertConfigRequest {
credentials: Record<string, unknown>
external_identity?: string
self_identity?: Record<string, unknown>
routing?: Record<string, unknown>
status?: string
}
export interface BotChannelItem {
meta: ChannelMeta
config: ChannelConfig | null
configured: boolean
}
// ---- Query: 获取可用渠道类型元信息 ----
export function useChannelMetas() {
return useQuery({
key: ['channel-metas'],
query: () => fetchApi<ChannelMeta[]>('/channels'),
})
}
// ---- Query: 获取某 Bot 的所有渠道配置(组合查询) ----
export function useBotChannels(botId: Ref<string>) {
const queryCache = useQueryCache()
const query = useQuery({
key: () => ['bot-channels', botId.value],
query: async (): Promise<BotChannelItem[]> => {
// 1. 获取所有渠道元信息
const metas = await fetchApi<ChannelMeta[]>('/channels')
// 2. 过滤掉 configless 的类型(cli / web 等本地渠道)
const configurableTypes = metas.filter((m) => !m.configless)
// 3. 并行获取每种类型的 bot 配置
const results = await Promise.all(
configurableTypes.map(async (meta) => {
try {
const config = await fetchApi<ChannelConfig>(
`/bots/${botId.value}/channel/${meta.type}`,
)
return { meta, config, configured: true } as BotChannelItem
} catch (err) {
// 404 = 尚未配置,其他错误也视为未配置
if (err instanceof ApiError && err.status === 404) {
return { meta, config: null, configured: false } as BotChannelItem
}
return { meta, config: null, configured: false } as BotChannelItem
}
}),
)
return results
},
enabled: () => !!botId.value,
})
return {
...query,
invalidate: () => queryCache.invalidateQueries({ key: ['bot-channels', botId.value] }),
}
}
// ---- Mutation: 创建/更新 Bot 渠道配置 ----
export function useUpsertBotChannel(botId: Ref<string>) {
const queryCache = useQueryCache()
return useMutation({
mutation: ({ platform, data }: { platform: string; data: UpsertConfigRequest }) =>
fetchApi<ChannelConfig>(`/bots/${botId.value}/channel/${platform}`, {
method: 'PUT',
body: data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['bot-channels', botId.value] }),
})
}
@@ -33,6 +33,15 @@ export function useModelList(providerId: Ref<string | undefined>) {
}
}
// ---- Query: 获取所有模型(跨 Provider ----
export function useAllModels() {
return useQuery({
key: ['all-models'],
query: () => fetchApi<ModelInfo[]>('/models'),
})
}
// ---- Mutations ----
export function useCreateModel() {
@@ -28,6 +28,14 @@ export function useProviderList(clientType: Ref<string>) {
})
}
/** 获取所有 Provider(无过滤) */
export function useAllProviders() {
return useQuery({
key: ['all-providers'],
query: () => fetchApi<ProviderWithId[]>('/providers'),
})
}
// ---- Mutations ----
export function useCreateProvider() {
+59
View File
@@ -27,6 +27,7 @@
},
"sidebar": {
"chat": "Chat",
"bots": "Bots",
"models": "Models",
"settings": "Settings",
"home": "Home",
@@ -117,5 +118,63 @@
},
"home": {
"title": "Home"
},
"bots": {
"title": "Bots",
"searchPlaceholder": "Search bots…",
"createBot": "New Bot",
"editBot": "Edit Bot",
"deleteConfirm": "Are you sure you want to delete this bot?",
"emptyTitle": "No Bots",
"emptyDescription": "Click the button above to create your first bot",
"displayName": "Name",
"displayNamePlaceholder": "Give your bot a name",
"avatarUrl": "Avatar URL",
"avatarUrlPlaceholder": "Enter avatar image URL",
"type": "Type",
"typePlaceholder": "Select bot type",
"active": "Active",
"inactive": "Inactive",
"createdAt": "Created at",
"tabs": {
"overview": "Overview",
"memory": "Memory",
"channels": "Channels",
"mcp": "MCP",
"subagents": "Subagents",
"history": "History",
"settings": "Settings"
},
"settings": {
"chatModel": "Chat Model",
"memoryModel": "Memory Model",
"embeddingModel": "Embedding Model",
"maxContextLoadTime": "Max Context Load Time",
"language": "Language",
"allowGuest": "Allow Guest Access",
"searchModel": "Search models…",
"noModel": "No models available",
"saveSuccess": "Settings saved",
"save": "Save Settings"
},
"channels": {
"title": "Channels",
"addChannel": "Add Channel",
"selectType": "Select channel type",
"configured": "Configured",
"notConfigured": "Not configured",
"emptyTitle": "No Channels",
"emptyDescription": "Click the button below to add a messaging channel",
"credentials": "Credentials",
"saveSuccess": "Channel configuration saved",
"saveFailed": "Failed to save",
"requiredField": "{field} is required",
"save": "Save Configuration",
"status": "Status",
"statusActive": "Active",
"statusInactive": "Inactive",
"deleteConfirm": "Are you sure you want to remove this channel?",
"noAvailableTypes": "All channel types have been configured"
}
}
}
+59
View File
@@ -27,6 +27,7 @@
},
"sidebar": {
"chat": "对话",
"bots": "Bots",
"models": "模型管理",
"settings": "设置",
"home": "首页",
@@ -117,5 +118,63 @@
},
"home": {
"title": "首页"
},
"bots": {
"title": "Bots",
"searchPlaceholder": "搜索 Bot…",
"createBot": "新建 Bot",
"editBot": "编辑 Bot",
"deleteConfirm": "确定要删除这个 Bot 吗?",
"emptyTitle": "暂无 Bot",
"emptyDescription": "点击右上角按钮创建你的第一个 Bot",
"displayName": "名称",
"displayNamePlaceholder": "给你的 Bot 起个名字",
"avatarUrl": "头像链接",
"avatarUrlPlaceholder": "输入头像图片地址",
"type": "类型",
"typePlaceholder": "选择 Bot 类型",
"active": "运行中",
"inactive": "未启用",
"createdAt": "创建于",
"tabs": {
"overview": "概览",
"memory": "记忆",
"channels": "渠道",
"mcp": "MCP",
"subagents": "子智能体",
"history": "对话历史",
"settings": "设置"
},
"settings": {
"chatModel": "对话模型",
"memoryModel": "记忆模型",
"embeddingModel": "向量模型",
"maxContextLoadTime": "最大上下文加载时间",
"language": "语言",
"allowGuest": "允许游客访问",
"searchModel": "搜索模型…",
"noModel": "暂无可选模型",
"saveSuccess": "设置已保存",
"save": "保存设置"
},
"channels": {
"title": "渠道",
"addChannel": "添加渠道",
"selectType": "选择渠道类型",
"configured": "已配置",
"notConfigured": "未配置",
"emptyTitle": "暂无渠道",
"emptyDescription": "点击下方按钮为 Bot 添加消息渠道",
"credentials": "凭据配置",
"saveSuccess": "渠道配置已保存",
"saveFailed": "保存失败",
"requiredField": "{field} 为必填项",
"save": "保存配置",
"status": "状态",
"statusActive": "启用",
"statusInactive": "停用",
"deleteConfirm": "确定要移除这个渠道吗?",
"noAvailableTypes": "所有渠道类型均已配置"
}
}
}
+10
View File
@@ -19,6 +19,11 @@ import {
faMagnifyingGlass,
faPlus,
faSpinner,
faCubes,
faPenToSquare,
faCheck,
faEye,
faEyeSlash,
} from '@fortawesome/free-solid-svg-icons'
import {
faRectangleList,
@@ -33,6 +38,11 @@ library.add(
faMagnifyingGlass,
faPlus,
faSpinner,
faCubes,
faPenToSquare,
faCheck,
faEye,
faEyeSlash,
faRectangleList,
faTrashCan,
faComments,
@@ -0,0 +1,106 @@
<template>
<Card
class="group relative transition-shadow hover:shadow-md cursor-pointer"
@click="router.push({ name: 'bot-detail', params: { botId: bot.id } })"
>
<CardHeader class="flex flex-row items-start gap-4 space-y-0">
<Avatar class="size-12 shrink-0">
<AvatarImage
v-if="bot.avatar_url"
:src="bot.avatar_url"
:alt="bot.display_name"
/>
<AvatarFallback class="text-lg">
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<CardTitle class="text-base truncate">
{{ bot.display_name || bot.id }}
</CardTitle>
<CardDescription class="mt-1 flex items-center gap-2">
<Badge
:variant="bot.is_active ? 'default' : 'secondary'"
class="text-xs"
>
{{ bot.is_active ? $t('bots.active') : $t('bots.inactive') }}
</Badge>
<span
v-if="bot.type"
class="text-xs text-muted-foreground"
>
{{ bot.type }}
</span>
</CardDescription>
</div>
</CardHeader>
<CardFooter class="pt-0 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ $t('bots.createdAt') }} {{ formattedDate }}</span>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
@click.stop="$emit('edit', bot)"
>
<FontAwesomeIcon :icon="['fas', 'pen-to-square']" />
</Button>
<ConfirmPopover
:message="$t('bots.deleteConfirm')"
:loading="deleteLoading"
@confirm="$emit('delete', bot.id)"
>
<template #trigger>
<Button
variant="ghost"
size="sm"
@click.stop
>
<FontAwesomeIcon :icon="['far', 'trash-can']" />
</Button>
</template>
</ConfirmPopover>
</div>
</CardFooter>
</Card>
</template>
<script setup lang="ts">
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
Avatar,
AvatarImage,
AvatarFallback,
Badge,
Button,
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { BotInfo } from '@/composables/api/useBots'
const router = useRouter()
const props = defineProps<{
bot: BotInfo
deleteLoading: boolean
}>()
defineEmits<{
edit: [bot: BotInfo]
delete: [id: string]
}>()
const avatarFallback = computed(() => {
const name = props.bot.display_name || props.bot.id
return name.slice(0, 2).toUpperCase()
})
const formattedDate = computed(() => {
if (!props.bot.created_at) return ''
return new Date(props.bot.created_at).toLocaleDateString()
})
</script>
@@ -0,0 +1,193 @@
<template>
<div class="flex gap-6 min-h-[400px]">
<!-- Left: Channel list -->
<div class="w-60 shrink-0 flex flex-col border rounded-lg">
<div class="flex-1 overflow-y-auto">
<!-- Loading -->
<div
v-if="isLoading && configuredChannels.length === 0"
class="flex items-center justify-center h-full p-4"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-4 animate-spin text-muted-foreground"
/>
</div>
<!-- Empty -->
<div
v-else-if="configuredChannels.length === 0"
class="flex flex-col items-center justify-center h-full p-4 text-center"
>
<p class="text-sm text-muted-foreground">
{{ $t('bots.channels.emptyTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('bots.channels.emptyDescription') }}
</p>
</div>
<!-- Configured channels -->
<div
v-else
class="p-1"
>
<button
v-for="item in configuredChannels"
:key="item.meta.type"
class="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
:class="{ 'bg-accent': selectedType === item.meta.type }"
@click="selectedType = item.meta.type"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-md text-xs font-bold uppercase"
:class="channelBadgeClass(item.meta.type)"
>
{{ channelIcon(item.meta.type) }}
</div>
<div class="flex-1 text-left">
<div class="font-medium">
{{ item.meta.display_name }}
</div>
<div class="text-xs">
<span
v-if="item.config?.status === 'active'"
class="text-green-600 dark:text-green-400"
>
{{ $t('bots.channels.statusActive') }}
</span>
<span
v-else
class="text-muted-foreground"
>
{{ $t('bots.channels.configured') }}
</span>
</div>
</div>
</button>
</div>
</div>
<!-- Add button -->
<div class="border-t p-2">
<Popover v-model:open="addPopoverOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
class="w-full"
size="sm"
:disabled="unconfiguredChannels.length === 0 && !isLoading"
>
<FontAwesomeIcon
:icon="['fas', 'plus']"
class="mr-2 size-3"
/>
{{ $t('bots.channels.addChannel') }}
</Button>
</PopoverTrigger>
<PopoverContent
class="w-56 p-1"
align="start"
>
<div
v-if="unconfiguredChannels.length === 0"
class="px-3 py-2 text-sm text-muted-foreground text-center"
>
{{ $t('bots.channels.noAvailableTypes') }}
</div>
<button
v-for="item in unconfiguredChannels"
:key="item.meta.type"
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
@click="addChannel(item.meta.type)"
>
<div
class="flex size-7 shrink-0 items-center justify-center rounded-md text-xs font-bold uppercase"
:class="channelBadgeClass(item.meta.type)"
>
{{ channelIcon(item.meta.type) }}
</div>
<span>{{ item.meta.display_name }}</span>
</button>
</PopoverContent>
</Popover>
</div>
</div>
<!-- Right: Channel settings -->
<div class="flex-1 min-w-0">
<div
v-if="!selectedType || !selectedItem"
class="flex h-full items-center justify-center text-sm text-muted-foreground"
>
{{ configuredChannels.length > 0 ? $t('bots.channels.selectType') : '' }}
</div>
<ChannelSettingsPanel
v-else
:key="selectedType"
:bot-id="botId"
:channel-item="selectedItem"
@saved="refetch()"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
Button,
Popover,
PopoverTrigger,
PopoverContent,
} from '@memoh/ui'
import { useBotChannels, type BotChannelItem } from '@/composables/api/useChannels'
import ChannelSettingsPanel from './channel-settings-panel.vue'
const props = defineProps<{
botId: string
}>()
const botIdRef = computed(() => props.botId)
const { data: channels, isLoading, refetch } = useBotChannels(botIdRef)
const selectedType = ref<string | null>(null)
const addPopoverOpen = ref(false)
const allChannels = computed<BotChannelItem[]>(() => channels.value ?? [])
const configuredChannels = computed(() => allChannels.value.filter((c) => c.configured))
const unconfiguredChannels = computed(() => allChannels.value.filter((c) => !c.configured))
const selectedItem = computed(() =>
allChannels.value.find((c) => c.meta.type === selectedType.value) ?? null,
)
//
watch(configuredChannels, (list) => {
if (list.length > 0 && !selectedType.value) {
selectedType.value = list[0].meta.type
}
}, { immediate: true })
function addChannel(type: string) {
addPopoverOpen.value = false
selectedType.value = type
}
function channelIcon(type: string): string {
const icons: Record<string, string> = {
telegram: 'TG',
feishu: '飞',
}
return icons[type] ?? type.slice(0, 2).toUpperCase()
}
function channelBadgeClass(type: string): string {
const classes: Record<string, string> = {
telegram: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
feishu: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300',
}
return classes[type] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
</script>
@@ -0,0 +1,162 @@
<template>
<div class="max-w-2xl space-y-6">
<!-- Chat Model -->
<div class="space-y-2">
<Label>{{ $t('bots.settings.chatModel') }}</Label>
<ModelSelect
v-model="form.chat_model_id"
:models="models"
:providers="providers"
model-type="chat"
:placeholder="$t('bots.settings.chatModel')"
/>
</div>
<!-- Memory Model -->
<div class="space-y-2">
<Label>{{ $t('bots.settings.memoryModel') }}</Label>
<ModelSelect
v-model="form.memory_model_id"
:models="models"
:providers="providers"
model-type="chat"
:placeholder="$t('bots.settings.memoryModel')"
/>
</div>
<!-- Embedding Model -->
<div class="space-y-2">
<Label>{{ $t('bots.settings.embeddingModel') }}</Label>
<ModelSelect
v-model="form.embedding_model_id"
:models="models"
:providers="providers"
model-type="embedding"
:placeholder="$t('bots.settings.embeddingModel')"
/>
</div>
<Separator />
<!-- Max Context Load Time -->
<div class="space-y-2">
<Label>{{ $t('bots.settings.maxContextLoadTime') }}</Label>
<Input
v-model.number="form.max_context_load_time"
type="number"
:min="0"
/>
</div>
<!-- Language -->
<div class="space-y-2">
<Label>{{ $t('bots.settings.language') }}</Label>
<Input
v-model="form.language"
type="text"
/>
</div>
<!-- Allow Guest -->
<div class="flex items-center justify-between">
<Label>{{ $t('bots.settings.allowGuest') }}</Label>
<Switch
:model-value="form.allow_guest"
@update:model-value="(val) => form.allow_guest = !!val"
/>
</div>
<Separator />
<!-- Save -->
<div class="flex justify-end">
<Button
:disabled="!hasChanges || isLoading"
@click="handleSave"
>
<Spinner v-if="isLoading" />
{{ $t('bots.settings.save') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import {
Label,
Input,
Switch,
Button,
Separator,
Spinner,
} from '@memoh/ui'
import { reactive, computed, watch } from 'vue'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import ModelSelect from './model-select.vue'
import { useBotSettings, useUpdateBotSettings, type BotSettings } from '@/composables/api/useBotSettings'
import { useAllModels } from '@/composables/api/useModels'
import { useAllProviders } from '@/composables/api/useProviders'
import type { Ref } from 'vue'
const props = defineProps<{
botId: string
}>()
const { t } = useI18n()
const botIdRef = computed(() => props.botId) as Ref<string>
// ---- Data ----
const { data: settings } = useBotSettings(botIdRef)
const { data: modelData } = useAllModels()
const { data: providerData } = useAllProviders()
const { mutateAsync: updateSettings, isLoading } = useUpdateBotSettings(botIdRef)
const models = computed(() => modelData.value ?? [])
const providers = computed(() => providerData.value ?? [])
// ---- Form ----
const form = reactive<BotSettings>({
chat_model_id: '',
memory_model_id: '',
embedding_model_id: '',
max_context_load_time: 0,
language: '',
allow_guest: false,
})
//
watch(settings, (val) => {
if (val) {
form.chat_model_id = val.chat_model_id ?? ''
form.memory_model_id = val.memory_model_id ?? ''
form.embedding_model_id = val.embedding_model_id ?? ''
form.max_context_load_time = val.max_context_load_time ?? 0
form.language = val.language ?? ''
form.allow_guest = val.allow_guest ?? false
}
}, { immediate: true })
const hasChanges = computed(() => {
if (!settings.value) return true
const s = settings.value
return (
form.chat_model_id !== (s.chat_model_id ?? '')
|| form.memory_model_id !== (s.memory_model_id ?? '')
|| form.embedding_model_id !== (s.embedding_model_id ?? '')
|| form.max_context_load_time !== (s.max_context_load_time ?? 0)
|| form.language !== (s.language ?? '')
|| form.allow_guest !== (s.allow_guest ?? false)
)
})
async function handleSave() {
try {
await updateSettings({ ...form })
toast.success(t('bots.settings.saveSuccess'))
} catch {
return
}
}
</script>
@@ -0,0 +1,264 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">
{{ channelItem.meta.display_name }}
</h3>
<p class="text-sm text-muted-foreground">
{{ channelItem.meta.type }}
</p>
</div>
<Badge :variant="channelItem.configured ? 'default' : 'secondary'">
{{ channelItem.configured ? $t('bots.channels.configured') : $t('bots.channels.notConfigured') }}
</Badge>
</div>
<Separator />
<!-- Credentials form (dynamic from config_schema) -->
<div class="space-y-4">
<h4 class="text-sm font-medium">
{{ $t('bots.channels.credentials') }}
</h4>
<div
v-for="(field, key) in orderedFields"
:key="key"
class="space-y-2"
>
<Label>
{{ field.title || key }}
<span
v-if="!field.required"
class="text-xs text-muted-foreground ml-1"
>({{ $t('common.optional') }})</span>
</Label>
<p
v-if="field.description"
class="text-xs text-muted-foreground"
>
{{ field.description }}
</p>
<!-- Secret field -->
<div
v-if="field.type === 'secret'"
class="relative"
>
<Input
v-model="form.credentials[key]"
:type="visibleSecrets[key] ? 'text' : 'password'"
:placeholder="field.example ? String(field.example) : ''"
/>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
@click="visibleSecrets[key] = !visibleSecrets[key]"
>
<FontAwesomeIcon
:icon="['fas', visibleSecrets[key] ? 'eye-slash' : 'eye']"
class="size-3.5"
/>
</button>
</div>
<!-- Boolean field -->
<Switch
v-else-if="field.type === 'bool'"
:model-value="!!form.credentials[key]"
@update:model-value="(val) => form.credentials[key] = !!val"
/>
<!-- Number field -->
<Input
v-else-if="field.type === 'number'"
v-model.number="form.credentials[key]"
type="number"
:placeholder="field.example ? String(field.example) : ''"
/>
<!-- Enum field -->
<Select
v-else-if="field.type === 'enum' && field.enum"
:model-value="String(form.credentials[key] || '')"
@update:model-value="(val) => form.credentials[key] = val"
>
<SelectTrigger>
<SelectValue :placeholder="field.title" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in field.enum"
:key="opt"
:value="opt"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
<!-- String field (default) -->
<Input
v-else
v-model="form.credentials[key]"
type="text"
:placeholder="field.example ? String(field.example) : ''"
/>
</div>
</div>
<Separator />
<!-- Status -->
<div class="flex items-center justify-between">
<Label>{{ $t('bots.channels.status') }}</Label>
<Switch
:model-value="form.status === 'active'"
@update:model-value="(val) => form.status = val ? 'active' : 'inactive'"
/>
</div>
<!-- Save -->
<div class="flex justify-end">
<Button
:disabled="isLoading"
@click="handleSave"
>
<Spinner v-if="isLoading" />
{{ $t('bots.channels.save') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import {
Badge,
Button,
Input,
Label,
Separator,
Switch,
Spinner,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@memoh/ui'
import { reactive, watch, computed } from 'vue'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import {
useUpsertBotChannel,
type BotChannelItem,
type FieldSchema,
} from '@/composables/api/useChannels'
import { ApiError } from '@/utils/request'
import type { Ref } from 'vue'
const props = defineProps<{
botId: string
channelItem: BotChannelItem
}>()
const emit = defineEmits<{
saved: []
}>()
const { t } = useI18n()
const botIdRef = computed(() => props.botId) as Ref<string>
const { mutateAsync: upsertChannel, isLoading } = useUpsertBotChannel(botIdRef)
// ---- Form state ----
const form = reactive<{
credentials: Record<string, unknown>
status: string
}>({
credentials: {},
status: 'active',
})
const visibleSecrets = reactive<Record<string, boolean>>({})
// Schema fields sorted: required first
const orderedFields = computed(() => {
const fields = props.channelItem.meta.config_schema?.fields ?? {}
const entries = Object.entries(fields)
entries.sort(([, a], [, b]) => {
if (a.required && !b.required) return -1
if (!a.required && b.required) return 1
return 0
})
return Object.fromEntries(entries) as Record<string, FieldSchema>
})
//
function initForm() {
const schema = props.channelItem.meta.config_schema?.fields ?? {}
const existingCredentials = props.channelItem.config?.credentials ?? {}
const creds: Record<string, unknown> = {}
for (const key of Object.keys(schema)) {
creds[key] = existingCredentials[key] ?? ''
}
form.credentials = creds
form.status = props.channelItem.config?.status ?? 'active'
}
watch(
() => props.channelItem,
() => initForm(),
{ immediate: true },
)
//
function validateRequired(): boolean {
const schema = props.channelItem.meta.config_schema?.fields ?? {}
for (const [key, field] of Object.entries(schema)) {
if (field.required) {
const val = form.credentials[key]
if (!val || (typeof val === 'string' && val.trim() === '')) {
toast.error(t('bots.channels.requiredField', { field: field.title || key }))
return false
}
}
}
return true
}
async function handleSave() {
if (!validateRequired()) return
try {
const credentials: Record<string, unknown> = {}
for (const [key, val] of Object.entries(form.credentials)) {
if (val !== '' && val !== undefined && val !== null) {
credentials[key] = val
}
}
await upsertChannel({
platform: props.channelItem.meta.type,
data: {
credentials,
status: form.status,
},
})
toast.success(t('bots.channels.saveSuccess'))
emit('saved')
} catch (err) {
let detail = ''
if (err instanceof ApiError && err.body) {
const body = err.body as Record<string, unknown>
detail = String(body.message || body.error || '')
} else if (err instanceof Error) {
detail = err.message
}
toast.error(detail ? `${t('bots.channels.saveFailed')}: ${detail}` : t('bots.channels.saveFailed'))
}
}
</script>
@@ -0,0 +1,202 @@
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<slot name="trigger">
<Button variant="default">
<FontAwesomeIcon
:icon="['fas', 'plus']"
class="mr-1.5"
/>
{{ $t('bots.createBot') }}
</Button>
</slot>
</DialogTrigger>
<DialogContent class="sm:max-w-md">
<form @submit="handleSubmit">
<DialogHeader>
<DialogTitle>{{ isEdit ? $t('bots.editBot') : $t('bots.createBot') }}</DialogTitle>
<DialogDescription>
<Separator class="my-4" />
</DialogDescription>
</DialogHeader>
<div class="flex flex-col gap-4">
<!-- Display Name -->
<FormField
v-slot="{ componentField }"
name="display_name"
>
<FormItem>
<Label class="mb-2">{{ $t('bots.displayName') }}</Label>
<FormControl>
<Input
type="text"
:placeholder="$t('bots.displayNamePlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<!-- Avatar URL -->
<FormField
v-slot="{ componentField }"
name="avatar_url"
>
<FormItem>
<Label class="mb-2">
{{ $t('bots.avatarUrl') }}
<span class="text-muted-foreground text-xs ml-1">({{ $t('common.optional') }})</span>
</Label>
<FormControl>
<Input
type="text"
:placeholder="$t('bots.avatarUrlPlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<!-- Type (only for create) -->
<FormField
v-if="!isEdit"
v-slot="{ componentField }"
name="type"
>
<FormItem>
<Label class="mb-2">
{{ $t('bots.type') }}
<span class="text-muted-foreground text-xs ml-1">({{ $t('common.optional') }})</span>
</Label>
<FormControl>
<Input
type="text"
:placeholder="$t('bots.typePlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<!-- Active (only for edit) -->
<FormField
v-if="isEdit"
v-slot="{ componentField }"
name="is_active"
>
<FormItem class="flex items-center justify-between">
<Label>{{ $t('bots.active') }}</Label>
<Switch
v-model="componentField.modelValue"
@update:model-value="componentField['onUpdate:modelValue']"
/>
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-6">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
type="submit"
:disabled="!form.meta.value.valid || submitLoading"
>
<Spinner v-if="submitLoading" />
{{ isEdit ? $t('common.save') : $t('bots.createBot') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Button,
FormField,
FormControl,
FormItem,
Separator,
Label,
Spinner,
Switch,
} from '@memoh/ui'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { computed, watch } from 'vue'
import { useCreateBot, useUpdateBot, type BotInfo } from '@/composables/api/useBots'
const open = defineModel<boolean>('open', { default: false })
const editBot = defineModel<BotInfo | null>('editBot', { default: null })
const isEdit = computed(() => !!editBot.value)
const formSchema = toTypedSchema(z.object({
display_name: z.string().min(1),
avatar_url: z.string().optional(),
type: z.string().optional(),
is_active: z.coerce.boolean().optional(),
}))
const form = useForm({
validationSchema: formSchema,
})
const { mutate: createBot, isLoading: createLoading } = useCreateBot()
const { mutate: updateBot, isLoading: updateLoading } = useUpdateBot()
const submitLoading = computed(() => createLoading.value || updateLoading.value)
//
watch(open, (val) => {
if (val && editBot.value) {
form.resetForm({
values: {
display_name: editBot.value.display_name,
avatar_url: editBot.value.avatar_url || '',
is_active: editBot.value.is_active,
},
})
} else if (!val) {
form.resetForm()
editBot.value = null
}
})
const handleSubmit = form.handleSubmit(async (values) => {
try {
if (isEdit.value && editBot.value) {
await updateBot({
id: editBot.value.id,
display_name: values.display_name,
avatar_url: values.avatar_url || undefined,
is_active: values.is_active,
})
} else {
await createBot({
display_name: values.display_name,
avatar_url: values.avatar_url || undefined,
type: values.type || undefined,
is_active: true,
})
}
open.value = false
} catch {
return
}
})
</script>
@@ -0,0 +1,157 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
class="w-full justify-between font-normal"
>
<span class="truncate">
{{ displayLabel || placeholder }}
</span>
<FontAwesomeIcon
:icon="['fas', 'magnifying-glass']"
class="ml-2 size-3.5 shrink-0 text-muted-foreground"
/>
</Button>
</PopoverTrigger>
<PopoverContent
class="w-[--reka-popover-trigger-width] p-0"
align="start"
>
<!-- Search input -->
<div class="flex items-center border-b px-3">
<FontAwesomeIcon
:icon="['fas', 'magnifying-glass']"
class="mr-2 size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="searchTerm"
:placeholder="$t('bots.settings.searchModel')"
class="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
>
</div>
<!-- Model list -->
<ScrollArea class="max-h-64">
<div
v-if="filteredGroups.length === 0"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('bots.settings.noModel') }}
</div>
<div
v-for="group in filteredGroups"
:key="group.providerName"
class="p-1"
>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ group.providerName }}
</div>
<button
v-for="model in group.models"
:key="model.model_id"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
:class="{ 'bg-accent': selected === model.model_id }"
@click="selectModel(model.model_id)"
>
<FontAwesomeIcon
v-if="selected === model.model_id"
:icon="['fas', 'check']"
class="size-3.5"
/>
<span
v-else
class="size-3.5"
/>
<span class="truncate">{{ model.name || model.model_id }}</span>
<span
v-if="model.name"
class="ml-auto text-xs text-muted-foreground"
>
{{ model.model_id }}
</span>
</button>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import {
Popover,
PopoverTrigger,
PopoverContent,
Button,
ScrollArea,
} from '@memoh/ui'
import { computed, ref, watch } from 'vue'
import type { ModelInfo } from '@memoh/shared'
import type { ProviderWithId } from '@/composables/api/useProviders'
const props = defineProps<{
models: ModelInfo[]
providers: ProviderWithId[]
modelType: 'chat' | 'embedding'
placeholder?: string
}>()
const selected = defineModel<string>({ default: '' })
const searchTerm = ref('')
const open = ref(false)
//
watch(open, (val) => {
if (val) searchTerm.value = ''
})
const typeFilteredModels = computed(() =>
props.models.filter((m) => m.type === props.modelType),
)
const providerMap = computed(() => {
const map = new Map<string, string>()
for (const p of props.providers) {
map.set(p.id, p.name ?? p.id)
}
return map
})
// Provider
const filteredGroups = computed(() => {
const keyword = searchTerm.value.trim().toLowerCase()
const models = keyword
? typeFilteredModels.value.filter(
(m) =>
m.model_id.toLowerCase().includes(keyword)
|| (m.name?.toLowerCase().includes(keyword) ?? false),
)
: typeFilteredModels.value
const groups = new Map<string, { providerName: string; models: ModelInfo[] }>()
for (const model of models) {
const pid = model.llm_provider_id
const providerName = providerMap.value.get(pid) ?? pid
if (!groups.has(pid)) {
groups.set(pid, { providerName, models: [] })
}
groups.get(pid)!.models.push(model)
}
return Array.from(groups.values())
})
//
const displayLabel = computed(() => {
if (!selected.value) return ''
const model = typeFilteredModels.value.find((m) => m.model_id === selected.value)
return model?.name || model?.model_id || selected.value
})
function selectModel(modelId: string) {
selected.value = modelId
open.value = false
}
</script>
+142
View File
@@ -0,0 +1,142 @@
<template>
<section class="p-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="flex items-center gap-4 mb-8">
<Avatar class="size-16 shrink-0">
<AvatarImage
v-if="bot?.avatar_url"
:src="bot.avatar_url"
:alt="bot.display_name"
/>
<AvatarFallback class="text-xl">
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
<div>
<h2 class="text-2xl font-semibold tracking-tight">
{{ bot?.display_name || botId }}
</h2>
<div class="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<Badge
v-if="bot"
:variant="bot.is_active ? 'default' : 'secondary'"
class="text-xs"
>
{{ bot.is_active ? $t('bots.active') : $t('bots.inactive') }}
</Badge>
<span v-if="bot?.type">{{ bot.type }}</span>
</div>
</div>
</div>
<!-- Tabs -->
<Tabs
v-model="activeTab"
class="w-full"
>
<TabsList class="w-full justify-start">
<TabsTrigger value="overview">
{{ $t('bots.tabs.overview') }}
</TabsTrigger>
<TabsTrigger value="memory">
{{ $t('bots.tabs.memory') }}
</TabsTrigger>
<TabsTrigger value="channels">
{{ $t('bots.tabs.channels') }}
</TabsTrigger>
<TabsTrigger value="mcp">
{{ $t('bots.tabs.mcp') }}
</TabsTrigger>
<TabsTrigger value="subagents">
{{ $t('bots.tabs.subagents') }}
</TabsTrigger>
<TabsTrigger value="history">
{{ $t('bots.tabs.history') }}
</TabsTrigger>
<TabsTrigger value="settings">
{{ $t('bots.tabs.settings') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="overview"
class="mt-6"
>
<!-- TODO: Overview content -->
</TabsContent>
<TabsContent
value="memory"
class="mt-6"
>
<!-- TODO: Memory content -->
</TabsContent>
<TabsContent
value="channels"
class="mt-6"
>
<BotChannels :bot-id="botId" />
</TabsContent>
<TabsContent
value="mcp"
class="mt-6"
>
<!-- TODO: MCP content -->
</TabsContent>
<TabsContent
value="subagents"
class="mt-6"
>
<!-- TODO: Subagents content -->
</TabsContent>
<TabsContent
value="history"
class="mt-6"
>
<!-- TODO: History content -->
</TabsContent>
<TabsContent
value="settings"
class="mt-6"
>
<BotSettings :bot-id="botId" />
</TabsContent>
</Tabs>
</section>
</template>
<script setup lang="ts">
import {
Avatar,
AvatarImage,
AvatarFallback,
Badge,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from '@memoh/ui'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useBotDetail } from '@/composables/api/useBots'
import BotSettings from './components/bot-settings.vue'
import BotChannels from './components/bot-channels.vue'
const route = useRoute()
const botId = computed(() => route.params.botId as string)
const { data: bot } = useBotDetail(botId)
// bot breadcrumb botId
watch(bot, (val) => {
if (val?.display_name) {
route.meta.breadcrumb = () => val.display_name
}
})
const activeTab = ref('overview')
const avatarFallback = computed(() => {
const name = bot.value?.display_name || botId.value || ''
return name.slice(0, 2).toUpperCase()
})
</script>
+106
View File
@@ -0,0 +1,106 @@
<template>
<section class="p-6 max-w-7xl mx-auto">
<!-- Header: search + create -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold tracking-tight">
{{ $t('bots.title') }}
</h3>
<div class="flex items-center gap-3">
<div class="relative">
<FontAwesomeIcon
:icon="['fas', 'magnifying-glass']"
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground size-3.5"
/>
<Input
v-model="searchText"
:placeholder="$t('bots.searchPlaceholder')"
class="pl-9 w-64"
/>
</div>
<CreateBot
v-model:open="dialogOpen"
v-model:edit-bot="editingBot"
/>
</div>
</div>
<!-- Bot grid -->
<div
v-if="filteredBots.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<BotCard
v-for="bot in filteredBots"
:key="bot.id"
:bot="bot"
:delete-loading="deleteLoading"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
<!-- Empty state -->
<Empty
v-else-if="!isLoading"
class="mt-20 flex flex-col items-center justify-center"
>
<EmptyHeader>
<EmptyMedia variant="icon">
<FontAwesomeIcon :icon="['fas', 'robot']" />
</EmptyMedia>
</EmptyHeader>
<EmptyTitle>{{ $t('bots.emptyTitle') }}</EmptyTitle>
<EmptyDescription>{{ $t('bots.emptyDescription') }}</EmptyDescription>
<EmptyContent />
</Empty>
</section>
</template>
<script setup lang="ts">
import {
Input,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@memoh/ui'
import { ref, computed } from 'vue'
import BotCard from './components/bot-card.vue'
import CreateBot from './components/create-bot.vue'
import { useBotList, useDeleteBot, type BotInfo } from '@/composables/api/useBots'
const searchText = ref('')
const dialogOpen = ref(false)
const editingBot = ref<BotInfo | null>(null)
const { data: botData, status } = useBotList()
const { mutate: deleteBot, isLoading: deleteLoading } = useDeleteBot()
const isLoading = computed(() => status.value === 'loading')
const filteredBots = computed(() => {
const list = botData.value ?? []
const keyword = searchText.value.trim().toLowerCase()
if (!keyword) return list
return list.filter((bot) =>
bot.display_name?.toLowerCase().includes(keyword)
|| bot.id.toLowerCase().includes(keyword)
|| bot.type?.toLowerCase().includes(keyword),
)
})
function handleEdit(bot: BotInfo) {
editingBot.value = bot
dialogOpen.value = true
}
async function handleDelete(id: string) {
try {
await deleteBot(id)
} catch {
return
}
}
</script>
+24 -1
View File
@@ -1,4 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
import { h } from 'vue'
import { RouterView } from 'vue-router'
import { i18nRef } from './i18n'
const routes = [
@@ -33,6 +35,27 @@ const routes = [
meta: {
breadcrumb: i18nRef('home.title')
}
}, {
path: 'bots',
component: { render: () => h(RouterView) },
meta: {
breadcrumb: i18nRef('sidebar.bots')
},
children: [
{
name: 'bots',
path: '',
component: () => import('@/pages/bots/index.vue'),
},
{
name: 'bot-detail',
path: ':botId',
component: () => import('@/pages/bots/detail.vue'),
meta: {
breadcrumb: (route: RouteLocationNormalized) => route.params.botId,
},
},
],
}, {
name: 'models',
path: 'models',
+6 -5
View File
@@ -4,8 +4,8 @@ import { useColorMode, useStorage } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
export interface Settings {
language: Locale
theme: 'light' | 'dark'
language: Locale;
theme: 'light' | 'dark';
}
export const useSettingsStore = defineStore('settings', () => {
@@ -14,8 +14,9 @@ export const useSettingsStore = defineStore('settings', () => {
const language = useStorage<Locale>('language', 'zh')
const theme = useStorage<'light' | 'dark'>('theme', 'light')
// 初始化时将 colorMode 与 theme 同步,避免 useColorMode 默认 'auto' 导致闪烁
// 立即同步持久化的设置到运行时状态
colorMode.value = theme.value
i18n.locale.value = language.value
const setLanguage = (value: Locale) => {
language.value = value
@@ -26,11 +27,11 @@ export const useSettingsStore = defineStore('settings', () => {
theme.value = value
colorMode.value = value
}
return {
language,
theme,
setLanguage,
setTheme,
}
})
})