feat(web): UI小幅调整,多处细节优化
This commit is contained in:
parent
950f289ffb
commit
3b61571bd8
@ -31,11 +31,12 @@ class DeepAgent(BaseAgent):
|
||||
name = "深度分析智能体"
|
||||
description = "具备规划、深度分析和子智能体协作能力的智能体,可以处理复杂的多步骤任务"
|
||||
context_schema = DeepContext
|
||||
capabilities = [
|
||||
"file_upload",
|
||||
"todo",
|
||||
"files",
|
||||
]
|
||||
capabilities = ["file_upload", "files", "todo"] # 支持文件上传功能
|
||||
metadata = {
|
||||
"examples": [
|
||||
"调研一下多模态 GraphRAG 的相关论文"
|
||||
]
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@ -11,12 +11,18 @@ organization:
|
||||
# 项目信息
|
||||
branding:
|
||||
name: "Yuxi"
|
||||
title: "Yuxi: 更智能的知识库智能体平台" # 系统标题
|
||||
subtitle: "大模型驱动的知识库智能体平台" # 副标题
|
||||
title: "让智能体可构建、可编排、可落地" # 系统标题
|
||||
subtitle: "开源智能体平台套件,融合 RAG 与知识图谱" # 副标题(subtitles 为空时使用)
|
||||
subtitles:
|
||||
- "开源智能体平台套件,融合 RAG 与知识图谱"
|
||||
- "统一编排 Agent、知识库、图谱与工具链"
|
||||
- "让智能体可构建,让知识可连接,让决策可验证"
|
||||
- "让智能体可落地,让流程可编排,让协作可扩展"
|
||||
- "让数据可沉淀,让能力可复用,让系统可进化"
|
||||
|
||||
features:
|
||||
- label: "GitHub Stars"
|
||||
value: "4100+"
|
||||
value: "4600+"
|
||||
description: "开发者社区的认可与支持"
|
||||
icon: "stars"
|
||||
- label: "已解决 Issues"
|
||||
@ -24,11 +30,11 @@ features:
|
||||
description: "持续改进和问题解决能力"
|
||||
icon: "issues"
|
||||
- label: "累计 Commits"
|
||||
value: "1300+"
|
||||
value: "1500+"
|
||||
description: "活跃的开发迭代和功能更新"
|
||||
icon: "commits"
|
||||
- label: "开源协议"
|
||||
value: "MIT 协议"
|
||||
value: "MIT License"
|
||||
description: "完全免费,支持商业使用"
|
||||
icon: "license"
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
## v0.6
|
||||
|
||||
<!-- 添加到这里 -->
|
||||
|
||||
- 调整 Agent 路由为 `/agent/{thread_id}`:`/agent` 进入未选中对话空态,不再在 URL 中展示 `agent_id`;发送首条消息时基于当前 `selectedAgentId` 与配置创建新对话,并自动跳转到对应线程路由。
|
||||
- 新增 API Key 管理功能,支持外部系统通过 API Key 调用 Agent 对话接口(`POST /api/chat/agent/{agent_id}`)。统一使用 `Authorization: Bearer <api_key>` 认证,API Key 以 `yxkey_` 开头。获取 API Key 即代表拥有绑定用户的所有接口访问权限。
|
||||
- 将 后端代码 和 agents 解耦,agents 作为单独的 package 使用
|
||||
- 添加 subagents
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 157 KiB |
@ -251,6 +251,7 @@ const props = defineProps({
|
||||
agentId: { type: String, default: '' },
|
||||
singleMode: { type: Boolean, default: true }
|
||||
})
|
||||
const emit = defineEmits(['thread-change'])
|
||||
|
||||
// ==================== STORE MANAGEMENT ====================
|
||||
const agentStore = useAgentStore()
|
||||
@ -516,7 +517,7 @@ onMounted(() => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width
|
||||
const isTakingSpace = chatUIStore.isSidebarOpen && !isSidebarFloating.value
|
||||
|
||||
|
||||
if (isTakingSpace) {
|
||||
if (width < 600) {
|
||||
isSidebarFloating.value = true
|
||||
@ -849,58 +850,21 @@ const sendMessage = async ({
|
||||
}
|
||||
|
||||
// ==================== CHAT ACTIONS ====================
|
||||
// 检查第一个对话是否为空
|
||||
const isFirstChatEmpty = () => {
|
||||
if (threads.value.length === 0) return false
|
||||
const chatToReuse = getFirstNonPinnedChat(threads.value)
|
||||
const messages = threadMessages.value[chatToReuse.id]
|
||||
// 只有当消息已加载且为空时才返回 true
|
||||
return messages !== undefined && messages.length === 0
|
||||
}
|
||||
|
||||
// 获取第一个非置顶的对话
|
||||
const getFirstNonPinnedChat = (chatList) => {
|
||||
if (!chatList || chatList.length === 0) return null
|
||||
return chatList.find((chat) => !chat.is_pinned) || chatList[0]
|
||||
}
|
||||
|
||||
// 如果第一个对话为空,直接切换到第一个非置顶对话
|
||||
const switchToFirstChatIfEmpty = async () => {
|
||||
if (threads.value.length > 0 && isFirstChatEmpty()) {
|
||||
const chatToReuse = getFirstNonPinnedChat(threads.value)
|
||||
if (chatState.currentThreadId !== chatToReuse.id) {
|
||||
await selectChat(chatToReuse.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const createNewChat = async () => {
|
||||
if (
|
||||
!AgentValidator.validateAgentId(currentAgentId.value, '创建对话') ||
|
||||
chatUIStore.creatingNewChat
|
||||
)
|
||||
return
|
||||
|
||||
// 如果第一个对话为空,直接切换到第一个对话而不是创建新对话
|
||||
if (await switchToFirstChatIfEmpty()) return
|
||||
|
||||
chatUIStore.creatingNewChat = true
|
||||
try {
|
||||
const newThread = await createThread(currentAgentId.value, '新的对话')
|
||||
if (newThread) {
|
||||
// 中断之前线程的流式输出(如果存在)
|
||||
const previousThreadId = chatState.currentThreadId
|
||||
stopThreadStream(previousThreadId)
|
||||
|
||||
chatState.currentThreadId = newThread.id
|
||||
}
|
||||
} catch (error) {
|
||||
handleChatError(error, 'create')
|
||||
} finally {
|
||||
chatUIStore.creatingNewChat = false
|
||||
const previousThreadId = chatState.currentThreadId
|
||||
if (previousThreadId) {
|
||||
stopThreadStream(previousThreadId)
|
||||
// run 模式下仅断开 SSE 订阅,不取消后台运行任务
|
||||
stopRunStreamSubscription(previousThreadId)
|
||||
}
|
||||
// 进入未选中对话空态,路由由 thread-change 统一同步到 /agent
|
||||
chatState.currentThreadId = null
|
||||
}
|
||||
|
||||
const selectChat = async (chatId) => {
|
||||
@ -951,6 +915,38 @@ const selectChat = async (chatId) => {
|
||||
await resumeActiveRunForThread(chatId)
|
||||
}
|
||||
|
||||
const selectThreadFromRoute = async (threadId) => {
|
||||
if (!agentStore.isInitialized) {
|
||||
await initAll()
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
const previousThreadId = chatState.currentThreadId
|
||||
if (previousThreadId) {
|
||||
stopThreadStream(previousThreadId)
|
||||
stopRunStreamSubscription(previousThreadId)
|
||||
}
|
||||
chatState.currentThreadId = null
|
||||
return true
|
||||
}
|
||||
|
||||
if (chatState.currentThreadId === threadId) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!threads.value.length || !threads.value.find((thread) => thread.id === threadId)) {
|
||||
await loadChatsList()
|
||||
}
|
||||
|
||||
const targetThread = threads.value.find((thread) => thread.id === threadId)
|
||||
if (!targetThread) {
|
||||
return false
|
||||
}
|
||||
|
||||
await selectChat(threadId)
|
||||
return true
|
||||
}
|
||||
|
||||
const deleteChat = async (chatId) => {
|
||||
if (
|
||||
!AgentValidator.validateAgentIdWithError(
|
||||
@ -964,11 +960,8 @@ const deleteChat = async (chatId) => {
|
||||
await deleteThread(chatId)
|
||||
if (chatState.currentThreadId === chatId) {
|
||||
chatState.currentThreadId = null
|
||||
// 如果删除的是当前对话,自动创建新对话
|
||||
// 删除当前对话后回到空态,发送消息时再创建新线程
|
||||
await createNewChat()
|
||||
} else if (chatsList.value.length > 0) {
|
||||
// 如果删除的不是当前对话,选择第一个非置顶可用对话
|
||||
await selectChat(getFirstNonPinnedChat(chatsList.value).id)
|
||||
}
|
||||
} catch (error) {
|
||||
handleChatError(error, 'delete')
|
||||
@ -1229,7 +1222,8 @@ const buildExportPayload = () => {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getExportPayload: buildExportPayload
|
||||
getExportPayload: buildExportPayload,
|
||||
selectThreadFromRoute
|
||||
})
|
||||
|
||||
const toggleSidebar = () => {
|
||||
@ -1341,8 +1335,8 @@ const loadChatsList = async () => {
|
||||
chatState.currentThreadId = null
|
||||
}
|
||||
|
||||
// 如果有线程但没有选中任何线程,自动选择第一个非置顶对话
|
||||
if (threads.value.length > 0 && !chatState.currentThreadId) {
|
||||
// singleMode 保持旧行为:自动选择首个可用对话
|
||||
if (props.singleMode && threads.value.length > 0 && !chatState.currentThreadId) {
|
||||
await selectChat(getFirstNonPinnedChat(threads.value).id)
|
||||
}
|
||||
} catch (error) {
|
||||
@ -1401,6 +1395,11 @@ watch(
|
||||
},
|
||||
{ deep: true, flush: 'post' }
|
||||
)
|
||||
|
||||
watch(currentChatId, (threadId, oldThreadId) => {
|
||||
if (threadId === oldThreadId) return
|
||||
emit('thread-change', threadId || '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
@ -51,12 +51,20 @@
|
||||
<!-- 侧边栏内容 -->
|
||||
<div class="sidebar-content">
|
||||
<div class="agent-info" v-if="selectedAgent">
|
||||
<div class="agent-basic-info">
|
||||
<!-- <div class="agent-basic-info">
|
||||
<p class="agent-description">{{ selectedAgent.description }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- <a-divider /> -->
|
||||
|
||||
<div class="config-segment" v-if="!isEmptyConfig">
|
||||
<a-segmented
|
||||
v-model:value="currentSegment"
|
||||
:options="segmentOptions"
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedAgentId && configurableItems"
|
||||
class="config-form-content"
|
||||
@ -72,7 +80,7 @@
|
||||
class="config-alert"
|
||||
/>
|
||||
<!-- 统一显示所有配置项 -->
|
||||
<template v-for="(value, key) in configurableItems" :key="key">
|
||||
<template v-for="(value, key) in filteredConfigurableItems" :key="key">
|
||||
<a-form-item
|
||||
v-if="shouldShowConfig(key, value)"
|
||||
:label="getConfigLabel(key, value)"
|
||||
@ -524,6 +532,12 @@ const createConfigModalOpen = ref(false)
|
||||
const createConfigLoading = ref(false)
|
||||
const createConfigName = ref('')
|
||||
const CREATE_CONFIG_OPTION_VALUE = '__create_config__'
|
||||
const currentSegment = ref('model')
|
||||
const segmentOptions = [
|
||||
{ label: '模型', value: 'model' },
|
||||
{ label: '工具', value: 'tools' },
|
||||
{ label: '其他', value: 'other' }
|
||||
]
|
||||
|
||||
const isEmptyConfig = computed(() => {
|
||||
return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0
|
||||
@ -554,6 +568,34 @@ const hasOtherConfigs = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const segmentConfigKeys = computed(() => {
|
||||
const keys = Object.keys(configurableItems.value)
|
||||
return {
|
||||
model: keys.filter(key => {
|
||||
const meta = configurableItems.value[key]?.template_metadata?.kind
|
||||
return meta === 'llm' || meta === 'prompt'
|
||||
}),
|
||||
tools: keys.filter(key => {
|
||||
const meta = configurableItems.value[key]?.template_metadata?.kind
|
||||
return ['tools', 'knowledges', 'mcps', 'skills', 'subagents'].includes(meta)
|
||||
}),
|
||||
other: keys.filter(key => {
|
||||
const meta = configurableItems.value[key]?.template_metadata?.kind
|
||||
return !['llm', 'prompt', 'tools', 'knowledges', 'mcps', 'skills', 'subagents'].includes(meta)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const filteredConfigurableItems = computed(() => {
|
||||
if (isEmptyConfig.value) return {}
|
||||
const keys = segmentConfigKeys.value[currentSegment.value] || []
|
||||
const filtered = {}
|
||||
keys.forEach(key => {
|
||||
filtered[key] = configurableItems.value[key]
|
||||
})
|
||||
return filtered
|
||||
})
|
||||
|
||||
const configSwitchOptions = computed(() => {
|
||||
if (!selectedAgentId.value) return []
|
||||
const list = agentConfigs.value[selectedAgentId.value] || []
|
||||
@ -1233,6 +1275,13 @@ const confirmDeleteConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.config-segment {
|
||||
margin: 0 auto;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 0;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.config-form-content {
|
||||
margin-bottom: 20px;
|
||||
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
<!-- 头部区域 -->
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<h3 class="title">API Key 管理</h3>
|
||||
<p class="description">
|
||||
<div class="section-title">API Key 管理</div>
|
||||
<p class="section-description">
|
||||
创建和管理 API Key,用于外部系统调用 Agent 对话接口。密钥仅显示一次,请妥善保管。
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showCreateModal" class="add-btn">
|
||||
<a-button type="primary" @click="showCreateModal" class="add-btn lucide-icon-btn">
|
||||
<Plus :size="14" />
|
||||
创建 API Key
|
||||
</a-button>
|
||||
@ -59,7 +59,7 @@
|
||||
type="text"
|
||||
size="small"
|
||||
@click="regenerateKey(key)"
|
||||
class="action-btn"
|
||||
class="action-btn lucide-icon-btn"
|
||||
>
|
||||
<RefreshCw :size="14" />
|
||||
<span>重新生成</span>
|
||||
@ -72,7 +72,7 @@
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" size="small" danger class="action-btn">
|
||||
<a-button type="text" size="small" danger class="action-btn lucide-icon-btn">
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</a-button>
|
||||
@ -128,7 +128,7 @@
|
||||
/>
|
||||
<div class="secret-value-container">
|
||||
<code class="secret-value">{{ createdSecret }}</code>
|
||||
<a-button type="primary" @click="copySecret" class="copy-btn">
|
||||
<a-button type="primary" @click="copySecret" class="copy-btn lucide-icon-btn">
|
||||
<Copy :size="14" />
|
||||
复制
|
||||
</a-button>
|
||||
@ -262,40 +262,6 @@ onMounted(() => {
|
||||
|
||||
<style lang="less" scoped>
|
||||
.apikey-management {
|
||||
padding: 12px;
|
||||
min-height: 50vh;
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
|
||||
.header-content {
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
.error-message {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@ -1,91 +1,112 @@
|
||||
<template>
|
||||
<div class="basic-settings-section">
|
||||
<h3 class="section-title">检索配置</h3>
|
||||
<div class="settings-panel">
|
||||
<div class="setting-row two-cols">
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.default_model?.des || '默认对话模型' }}</div>
|
||||
<div class="setting-content">
|
||||
<ModelSelectorComponent
|
||||
@select-model="handleChatModelSelect"
|
||||
:model_spec="configStore.config?.default_model"
|
||||
placeholder="请选择默认模型"
|
||||
/>
|
||||
<template v-if="userStore.isSuperAdmin">
|
||||
<div class="section-title">检索配置</div>
|
||||
<div class="settings-panel">
|
||||
<div class="setting-row two-cols">
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.default_model?.des || '默认对话模型' }}</div>
|
||||
<div class="setting-content">
|
||||
<ModelSelectorComponent
|
||||
@select-model="handleChatModelSelect"
|
||||
:model_spec="configStore.config?.default_model"
|
||||
placeholder="请选择默认模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.fast_model?.des }}</div>
|
||||
<div class="setting-content">
|
||||
<ModelSelectorComponent
|
||||
@select-model="handleFastModelSelect"
|
||||
:model_spec="configStore.config?.fast_model"
|
||||
placeholder="请选择模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.fast_model.des }}</div>
|
||||
<div class="setting-content">
|
||||
<ModelSelectorComponent
|
||||
@select-model="handleFastModelSelect"
|
||||
:model_spec="configStore.config?.fast_model"
|
||||
placeholder="请选择模型"
|
||||
/>
|
||||
<div class="setting-row two-cols">
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.embed_model?.des }}</div>
|
||||
<div class="setting-content">
|
||||
<EmbeddingModelSelector
|
||||
:value="configStore.config?.embed_model"
|
||||
@change="handleChange('embed_model', $event)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.reranker?.des }}</div>
|
||||
<div class="setting-content">
|
||||
<a-select
|
||||
class="full-width"
|
||||
:value="configStore.config?.reranker"
|
||||
@change="handleChange('reranker', $event)"
|
||||
placeholder="请选择重排序模型"
|
||||
>
|
||||
<a-select-option v-for="(name, idx) in rerankerChoices" :key="idx" :value="name"
|
||||
>{{ name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row two-cols">
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.embed_model.des }}</div>
|
||||
<div class="setting-content">
|
||||
<EmbeddingModelSelector
|
||||
:value="configStore.config?.embed_model"
|
||||
@change="handleChange('embed_model', $event)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-item">
|
||||
<div class="setting-label">{{ items?.reranker.des }}</div>
|
||||
<div class="setting-content">
|
||||
<a-select
|
||||
class="full-width"
|
||||
:value="configStore.config?.reranker"
|
||||
@change="handleChange('reranker', $event)"
|
||||
placeholder="请选择重排序模型"
|
||||
>
|
||||
<a-select-option v-for="(name, idx) in rerankerChoices" :key="idx" :value="name"
|
||||
>{{ name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">内容审查配置</h3>
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<span class="label">{{ items?.enable_content_guard.des }}</span>
|
||||
<a-switch
|
||||
:checked="configStore.config?.enable_content_guard"
|
||||
@change="handleChange('enable_content_guard', $event)"
|
||||
/>
|
||||
<div class="section-title">内容审查配置</div>
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<span class="label">{{ items?.enable_content_guard?.des }}</span>
|
||||
<a-switch
|
||||
:checked="configStore.config?.enable_content_guard"
|
||||
@change="handleChange('enable_content_guard', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="card" v-if="configStore.config?.enable_content_guard">
|
||||
<span class="label">{{ items?.enable_content_guard_llm?.des }}</span>
|
||||
<a-switch
|
||||
:checked="configStore.config?.enable_content_guard_llm"
|
||||
@change="handleChange('enable_content_guard_llm', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="card card-select"
|
||||
v-if="
|
||||
configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm
|
||||
"
|
||||
>
|
||||
<span class="label">{{ items?.content_guard_llm_model?.des }}</span>
|
||||
<ModelSelectorComponent
|
||||
@select-model="handleContentGuardModelSelect"
|
||||
:model_spec="configStore.config?.content_guard_llm_model"
|
||||
placeholder="请选择模型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" v-if="configStore.config?.enable_content_guard">
|
||||
<span class="label">{{ items?.enable_content_guard_llm.des }}</span>
|
||||
<a-switch
|
||||
:checked="configStore.config?.enable_content_guard_llm"
|
||||
@change="handleChange('enable_content_guard_llm', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="card card-select"
|
||||
v-if="
|
||||
configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm
|
||||
"
|
||||
>
|
||||
<span class="label">{{ items?.content_guard_llm_model.des }}</span>
|
||||
<ModelSelectorComponent
|
||||
@select-model="handleContentGuardModelSelect"
|
||||
:model_spec="configStore.config?.content_guard_llm_model"
|
||||
placeholder="请选择模型"
|
||||
</template>
|
||||
|
||||
<div v-if="userStore.isAdmin" class="section-title">智能体配置</div>
|
||||
<div v-if="userStore.isAdmin" class="section">
|
||||
<div class="card card-select agent-card">
|
||||
<div class="label-group">
|
||||
<span class="label">默认智能体</span>
|
||||
<span class="helper-text">用于默认进入的智能体,也作为新会话的默认选择。</span>
|
||||
</div>
|
||||
<a-select
|
||||
class="agent-select"
|
||||
:value="agentStore.defaultAgentId"
|
||||
:options="agentOptions"
|
||||
:loading="isSettingDefaultAgent"
|
||||
:disabled="isSettingDefaultAgent || !agentOptions.length"
|
||||
placeholder="请选择默认智能体"
|
||||
@change="handleDefaultAgentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务链接部分 -->
|
||||
<h3 v-if="userStore.isAdmin" class="section-title">服务链接</h3>
|
||||
<div v-if="userStore.isAdmin" class="section-title">服务链接</div>
|
||||
<div v-if="userStore.isAdmin">
|
||||
<p class="service-description">
|
||||
快速访问系统相关的外部服务,需要将 localhost 替换为实际的 IP 地址。
|
||||
@ -98,8 +119,9 @@
|
||||
</div>
|
||||
<a-button
|
||||
type="default"
|
||||
class="lucide-icon-btn"
|
||||
@click="openLink('http://localhost:7474/')"
|
||||
:icon="h(GlobalOutlined)"
|
||||
:icon="h(Globe, { size: 18 })"
|
||||
>
|
||||
访问
|
||||
</a-button>
|
||||
@ -112,8 +134,9 @@
|
||||
</div>
|
||||
<a-button
|
||||
type="default"
|
||||
class="lucide-icon-btn"
|
||||
@click="openLink('http://localhost:5050/docs')"
|
||||
:icon="h(GlobalOutlined)"
|
||||
:icon="h(Globe, { size: 18 })"
|
||||
>
|
||||
访问
|
||||
</a-button>
|
||||
@ -126,8 +149,9 @@
|
||||
</div>
|
||||
<a-button
|
||||
type="default"
|
||||
class="lucide-icon-btn"
|
||||
@click="openLink('http://localhost:9001')"
|
||||
:icon="h(GlobalOutlined)"
|
||||
:icon="h(Globe, { size: 18 })"
|
||||
>
|
||||
访问
|
||||
</a-button>
|
||||
@ -140,8 +164,9 @@
|
||||
</div>
|
||||
<a-button
|
||||
type="default"
|
||||
class="lucide-icon-btn"
|
||||
@click="openLink('http://localhost:9091/webui/')"
|
||||
:icon="h(GlobalOutlined)"
|
||||
:icon="h(Globe, { size: 18 })"
|
||||
>
|
||||
访问
|
||||
</a-button>
|
||||
@ -152,41 +177,36 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { GlobalOutlined } from '@ant-design/icons-vue'
|
||||
import { Globe } from 'lucide-vue-next'
|
||||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||||
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const agentStore = useAgentStore()
|
||||
const userStore = useUserStore()
|
||||
const items = computed(() => configStore.config._config_items)
|
||||
const items = computed(() => configStore.config?._config_items || {})
|
||||
const isSettingDefaultAgent = ref(false)
|
||||
|
||||
const rerankerChoices = computed(() => {
|
||||
return Object.keys(configStore?.config?.reranker_names || {}) || []
|
||||
})
|
||||
|
||||
const preHandleChange = (key, e) => {
|
||||
return true
|
||||
}
|
||||
const agentOptions = computed(() =>
|
||||
(agentStore.agents || []).map((agent) => ({
|
||||
label: agent.name || 'Unknown',
|
||||
value: agent.id
|
||||
}))
|
||||
)
|
||||
|
||||
const handleChange = (key, e) => {
|
||||
if (!preHandleChange(key, e)) {
|
||||
return
|
||||
}
|
||||
configStore.setConfigValue(key, e)
|
||||
}
|
||||
|
||||
const handleChanges = (items) => {
|
||||
for (const key in items) {
|
||||
if (!preHandleChange(key, items[key])) {
|
||||
return
|
||||
}
|
||||
}
|
||||
configStore.setConfigValues(items)
|
||||
}
|
||||
|
||||
const handleChatModelSelect = (spec) => {
|
||||
if (typeof spec === 'string' && spec) {
|
||||
configStore.setConfigValue('default_model', spec)
|
||||
@ -205,9 +225,30 @@ const handleContentGuardModelSelect = (spec) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDefaultAgentChange = async (agentId) => {
|
||||
if (!agentId || agentId === agentStore.defaultAgentId) return
|
||||
|
||||
isSettingDefaultAgent.value = true
|
||||
try {
|
||||
await agentStore.setDefaultAgent(agentId)
|
||||
message.success('默认智能体已更新')
|
||||
} finally {
|
||||
isSettingDefaultAgent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openLink = (url) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!agentStore.agents.length) {
|
||||
await agentStore.fetchAgents()
|
||||
}
|
||||
if (!agentStore.defaultAgentId) {
|
||||
await agentStore.fetchDefaultAgent()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -312,6 +353,28 @@ const openLink = (url) => {
|
||||
}
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.helper-text {
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.agent-select {
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
@ -331,8 +394,8 @@ const openLink = (url) => {
|
||||
min-height: 70px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 1px 8px var(--gray-200);
|
||||
border-color: var(--main-200);
|
||||
box-shadow: 0 1px 8px var(--gray-150);
|
||||
border-color: var(--gray-100);
|
||||
}
|
||||
|
||||
.service-info {
|
||||
@ -343,7 +406,7 @@ const openLink = (url) => {
|
||||
margin: 0 0 4px 0;
|
||||
color: var(--gray-900);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
@ -354,5 +417,16 @@ const openLink = (url) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agent-card {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
<!-- 头部区域 -->
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<h3 class="title">部门管理</h3>
|
||||
<p class="description">管理系统部门,部门下的用户会被隔离管理。</p>
|
||||
<div class="section-title">部门管理</div>
|
||||
<p class="section-description">管理系统部门,部门下的用户会被隔离管理。</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showAddDepartmentModal" class="add-btn">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<a-button type="primary" @click="showAddDepartmentModal" class="add-btn lucide-icon-btn">
|
||||
<template #icon><Plus :size="16" /></template>
|
||||
添加部门
|
||||
</a-button>
|
||||
</div>
|
||||
@ -46,9 +46,9 @@
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showEditDepartmentModal(record)"
|
||||
class="action-btn"
|
||||
class="action-btn lucide-icon-btn"
|
||||
>
|
||||
<EditOutlined />
|
||||
<Pencil :size="14" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除部门">
|
||||
@ -58,9 +58,9 @@
|
||||
danger
|
||||
@click="confirmDeleteDepartment(record)"
|
||||
:disabled="record.user_count > 0"
|
||||
class="action-btn"
|
||||
class="action-btn lucide-icon-btn"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
<Trash2 :size="14" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
@ -110,7 +110,7 @@
|
||||
|
||||
<template v-if="!departmentManagement.editMode">
|
||||
<div class="admin-section-title">
|
||||
<TeamOutlined />
|
||||
<Users :size="16" />
|
||||
<span>部门管理员</span>
|
||||
</div>
|
||||
<p class="admin-section-hint">
|
||||
@ -170,7 +170,7 @@
|
||||
import { reactive, onMounted, watch } from 'vue'
|
||||
import { notification, Modal } from 'ant-design-vue'
|
||||
import { departmentApi, apiSuperAdminGet } from '@/apis'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||||
import { Delete as Trash2, Edit3 as Pencil, Plus, Users } from 'lucide-vue-next'
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
@ -454,27 +454,6 @@ onMounted(() => {
|
||||
|
||||
<style lang="less" scoped>
|
||||
.department-management {
|
||||
margin-top: 12px;
|
||||
min-height: 50vh;
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--gray-600);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
@ -299,31 +299,6 @@
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="prompts">
|
||||
<template #tab>
|
||||
<span class="tab-title"><MessageSquare :size="14" />提示</span>
|
||||
</template>
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="提示功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📝</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="resources">
|
||||
<template #tab>
|
||||
<span class="tab-title"><Box :size="14" />资源</span>
|
||||
</template>
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="资源功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📦</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
</div>
|
||||
@ -485,9 +460,7 @@ import {
|
||||
Info,
|
||||
Copy,
|
||||
Settings2,
|
||||
Wrench,
|
||||
MessageSquare,
|
||||
Box
|
||||
Wrench
|
||||
} from 'lucide-vue-next'
|
||||
import { mcpApi } from '@/apis/mcp_api'
|
||||
import { formatFullDateTime } from '@/utils/time'
|
||||
|
||||
@ -1,23 +1,22 @@
|
||||
<template>
|
||||
<div class="model-providers-section">
|
||||
<!-- 自定义供应商管理区域 -->
|
||||
<h3>模型配置</h3>
|
||||
<p>请在 <code>.env</code> 文件中配置对应的 APIKEY,并重新启动服务</p>
|
||||
<a-divider />
|
||||
<div class="custom-providers-section">
|
||||
<div class="section-header">
|
||||
<h3>自定义供应商</h3>
|
||||
<a-button type="primary" @click="openAddCustomProviderModal">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
添加自定义供应商
|
||||
</a-button>
|
||||
</div>
|
||||
<p class="section-description">
|
||||
添加自定义的LLM供应商,支持OpenAI兼容的API格式。API密钥支持直接配置或使用环境变量名。
|
||||
</p>
|
||||
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<div class="section-title">自定义供应商</div>
|
||||
<p class="section-description">
|
||||
添加自定义的LLM供应商,支持OpenAI兼容的API格式。
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="openAddCustomProviderModal" class="add-btn lucide-icon-btn">
|
||||
<template #icon>
|
||||
<Plus :size="16" />
|
||||
</template>
|
||||
添加自定义供应商
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="custom-providers-section">
|
||||
<!-- 自定义供应商列表 -->
|
||||
<div
|
||||
class="custom-provider-card"
|
||||
@ -33,20 +32,22 @@
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="lucide-icon-btn"
|
||||
@click="testCustomProvider(providerId, provider.default)"
|
||||
>
|
||||
<template #icon>
|
||||
<ApiOutlined />
|
||||
<PlugZap :size="14" />
|
||||
</template>
|
||||
测试连接
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="lucide-icon-btn"
|
||||
@click="openEditCustomProviderModal(providerId, provider)"
|
||||
>
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
<Pencil :size="14" />
|
||||
</template>
|
||||
编辑
|
||||
</a-button>
|
||||
@ -56,9 +57,9 @@
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="text" size="small" danger>
|
||||
<a-button type="text" size="small" danger class="lucide-icon-btn">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
<Trash2 :size="14" />
|
||||
</template>
|
||||
删除
|
||||
</a-button>
|
||||
@ -86,7 +87,7 @@
|
||||
<!-- 无自定义供应商时的提示 -->
|
||||
<div v-if="Object.keys(customProviders).length === 0" class="empty-state">
|
||||
<a-empty description="暂无自定义供应商">
|
||||
<a-button type="primary" @click="openAddCustomProviderModal">添加自定义供应商</a-button>
|
||||
<!-- <a-button type="primary" @click="openAddCustomProviderModal">添加自定义供应商</a-button> -->
|
||||
</a-empty>
|
||||
</div>
|
||||
</div>
|
||||
@ -96,12 +97,13 @@
|
||||
<!-- 系统内置供应商 -->
|
||||
<div class="builtin-providers-section">
|
||||
<div class="section-header">
|
||||
<h3>系统内置供应商</h3>
|
||||
<div class="section-subtitle">系统内置供应商</div>
|
||||
<div class="providers-stats">
|
||||
<span class="stats-item available"> {{ modelKeys.length }} 可用 </span>
|
||||
<span class="stats-item unavailable"> {{ notModelKeys.length }} 未配置 </span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-description">请在 <code>.env</code> 文件中配置对应的 APIKEY,并重新启动服务</p>
|
||||
|
||||
<!-- 已配置的供应商 -->
|
||||
<div
|
||||
@ -114,16 +116,16 @@
|
||||
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标" />
|
||||
</div>
|
||||
<div class="model-title-container">
|
||||
<h3>{{ modelNames[item].name }}</h3>
|
||||
<div class="model-name">{{ modelNames[item].name }}</div>
|
||||
</div>
|
||||
<div class="provider-meta">
|
||||
<a-button
|
||||
type="text"
|
||||
class="expand-button"
|
||||
class="expand-button lucide-icon-btn"
|
||||
@click.stop="openProviderConfig(item)"
|
||||
title="配置模型"
|
||||
>
|
||||
<SettingOutlined /> 已选 {{ modelNames[item].models?.length || 0 }} 个模型
|
||||
<Settings :size="14" /> 已选 {{ modelNames[item].models?.length || 0 }} 个模型
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@ -147,9 +149,9 @@
|
||||
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标" />
|
||||
</div>
|
||||
<div class="model-title-container">
|
||||
<h3>{{ modelNames[item].name }}</h3>
|
||||
<div class="model-name">{{ modelNames[item].name }}</div>
|
||||
<a :href="modelNames[item].url" target="_blank" class="model-url">
|
||||
查看信息 <InfoCircleOutlined />
|
||||
查看信息 <CircleHelp :size="13" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="missing-keys">
|
||||
@ -173,9 +175,7 @@
|
||||
>
|
||||
<div v-if="providerConfig.loading" class="modal-loading-container">
|
||||
<a-spin
|
||||
:indicator="
|
||||
h(LoadingOutlined, { style: { fontSize: '32px', color: 'var(--main-color)' } })
|
||||
"
|
||||
:indicator="h(LoaderCircle, { size: 32, color: 'var(--main-color)' })"
|
||||
/>
|
||||
<div class="loading-text">正在获取模型列表...</div>
|
||||
</div>
|
||||
@ -225,7 +225,7 @@
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
<Search :size="14" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
@ -384,16 +384,15 @@
|
||||
import { computed, reactive, watch, h, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
InfoCircleOutlined, // Keep if still used for other things, if not, remove. For now assume it might be used elsewhere.
|
||||
SettingOutlined,
|
||||
DownCircleOutlined,
|
||||
LoadingOutlined,
|
||||
SearchOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ApiOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
CircleHelp,
|
||||
Settings,
|
||||
LoaderCircle,
|
||||
Search,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
PlugZap
|
||||
} from 'lucide-vue-next'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { modelIcons } from '@/utils/modelIcon'
|
||||
import { agentApi } from '@/apis/agent_api'
|
||||
@ -821,20 +820,9 @@ const testCustomProvider = async (providerId, modelName) => {
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-providers-section {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.custom-providers-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-description {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--gray-600);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.custom-provider-card {
|
||||
border: 1px solid var(--gray-200);
|
||||
background: var(--gray-0);
|
||||
@ -858,7 +846,7 @@ const testCustomProvider = async (providerId, modelName) => {
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
@ -906,7 +894,7 @@ const testCustomProvider = async (providerId, modelName) => {
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--gray-25);
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--gray-300);
|
||||
@ -919,14 +907,16 @@ const testCustomProvider = async (providerId, modelName) => {
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
.section-subtitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
}
|
||||
|
||||
.builtin-providers-section {
|
||||
|
||||
.section-header {
|
||||
.providers-stats {
|
||||
display: flex;
|
||||
@ -964,7 +954,7 @@ const testCustomProvider = async (providerId, modelName) => {
|
||||
border: 1px solid var(--gray-150);
|
||||
background-color: var(--gray-0);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
margin: 16px 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@ -980,7 +970,7 @@ const testCustomProvider = async (providerId, modelName) => {
|
||||
.card-header {
|
||||
background: var(--gray-25);
|
||||
|
||||
h3 {
|
||||
.model-name {
|
||||
color: var(--gray-700);
|
||||
font-weight: 500;
|
||||
}
|
||||
@ -1004,7 +994,7 @@ const testCustomProvider = async (providerId, modelName) => {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
.model-name {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@ -1,25 +1,30 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="系统设置"
|
||||
:title="null"
|
||||
width="90%"
|
||||
:style="{ maxWidth: '980px', minWidth: '320px', top: '10%' }"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
@cancel="handleClose"
|
||||
class="settings-modal"
|
||||
:destroyOnClose="true"
|
||||
:bodyStyle="{ padding: 0 }"
|
||||
>
|
||||
<div class="settings-container">
|
||||
<button class="settings-close-btn lucide-icon-btn" @click="handleClose" aria-label="关闭设置">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- 侧边栏 (Desktop) -->
|
||||
<div class="settings-sider">
|
||||
<div
|
||||
class="sider-item"
|
||||
:class="{ activesec: activeTab === 'base' }"
|
||||
@click="activeTab = 'base'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-if="userStore.isAdmin"
|
||||
>
|
||||
<SettingOutlined class="icon" />
|
||||
<Settings class="icon" :size="18" />
|
||||
<span>基本设置</span>
|
||||
</div>
|
||||
<div
|
||||
@ -28,7 +33,7 @@
|
||||
@click="activeTab = 'model'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
>
|
||||
<CodeOutlined class="icon" />
|
||||
<SquareCode class="icon" :size="18" />
|
||||
<span>模型配置</span>
|
||||
</div>
|
||||
<div
|
||||
@ -37,7 +42,7 @@
|
||||
@click="activeTab = 'user'"
|
||||
v-if="userStore.isAdmin"
|
||||
>
|
||||
<UserOutlined class="icon" />
|
||||
<User class="icon" :size="18" />
|
||||
<span>用户管理</span>
|
||||
</div>
|
||||
<div
|
||||
@ -46,7 +51,7 @@
|
||||
@click="activeTab = 'department'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
>
|
||||
<TeamOutlined class="icon" />
|
||||
<Users class="icon" :size="18" />
|
||||
<span>部门管理</span>
|
||||
</div>
|
||||
<div
|
||||
@ -55,7 +60,7 @@
|
||||
@click="activeTab = 'apikey'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
>
|
||||
<KeyIcon class="icon" :size="14" />
|
||||
<KeyIcon class="icon" :size="18" />
|
||||
<span>API Key</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -66,7 +71,7 @@
|
||||
class="nav-item"
|
||||
:class="{ active: activeTab === 'base' }"
|
||||
@click="activeTab = 'base'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-if="userStore.isAdmin"
|
||||
>
|
||||
基本设置
|
||||
</div>
|
||||
@ -107,7 +112,7 @@
|
||||
<!-- 内容区域 -->
|
||||
<div class="settings-content-wrapper">
|
||||
<div class="settings-content">
|
||||
<div v-show="activeTab === 'base'" v-if="userStore.isSuperAdmin">
|
||||
<div v-show="activeTab === 'base'" v-if="userStore.isAdmin">
|
||||
<BasicSettingsSection />
|
||||
</div>
|
||||
|
||||
@ -135,8 +140,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { SettingOutlined, CodeOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||||
import { Key as KeyIcon } from 'lucide-vue-next'
|
||||
import { Key as KeyIcon, Settings, SquareCode, User, Users, X } from 'lucide-vue-next'
|
||||
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
|
||||
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
|
||||
import UserManagementComponent from '@/components/UserManagementComponent.vue'
|
||||
@ -169,10 +173,8 @@ watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (userStore.isSuperAdmin) {
|
||||
if (userStore.isAdmin) {
|
||||
activeTab.value = 'base'
|
||||
} else if (userStore.isAdmin) {
|
||||
activeTab.value = 'user'
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -180,46 +182,68 @@ watch(
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.settings-modal {
|
||||
:deep(.ant-modal-header) {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-modal-content) {
|
||||
border-radius: 8px;
|
||||
.settings-modal.ant-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: 70vh;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--gray-50);
|
||||
color: var(--gray-700);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-200);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar Styles - Matching SettingView.vue style */
|
||||
.settings-sider {
|
||||
width: 128px;
|
||||
width: 176px;
|
||||
height: 100%;
|
||||
padding-top: 8px;
|
||||
padding: 52px 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
background: var(--gray-50);
|
||||
border-right: 1px solid var(--gray-150);
|
||||
|
||||
@media (max-width: 900px) {
|
||||
display: none;
|
||||
@ -237,7 +261,6 @@ watch(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: -20px;
|
||||
|
||||
.icon {
|
||||
font-size: 14px; /* Slightly adjusted to align better, SettingView uses h() icon defaults */
|
||||
@ -248,7 +271,7 @@ watch(
|
||||
}
|
||||
|
||||
&.activesec {
|
||||
background: var(--gray-100);
|
||||
background: var(--gray-150);
|
||||
color: var(--main-700);
|
||||
}
|
||||
}
|
||||
@ -258,30 +281,79 @@ watch(
|
||||
.settings-content-wrapper {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
max-width: calc(100% - 128px);
|
||||
max-width: calc(100% - 176px);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--gray-0);
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
max-width: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 0; /* Matches SettingView .setting padding */
|
||||
padding: 12px 16px 16px; /* Keep inner readability without outer panel padding */
|
||||
// margin-bottom: 40px; /* Matches SettingView .setting margin-bottom */
|
||||
overflow-y: scroll;
|
||||
height: 70vh;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.model-providers-section,
|
||||
.user-management,
|
||||
.department-management,
|
||||
.apikey-management {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-900);
|
||||
line-height: 1.4;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 14px;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
height: 70vh;
|
||||
padding: 0px;
|
||||
height: auto;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* BasicSettingsSection has its own h3 styles which might conflict slightly but are mostly self-contained */
|
||||
}
|
||||
}
|
||||
|
||||
@ -292,6 +364,7 @@ watch(
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
background: var(--gray-0);
|
||||
padding: 0;
|
||||
padding-left: 42px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@ -66,9 +66,8 @@
|
||||
<!-- 个人资料弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="profileModalVisible"
|
||||
title="个人资料"
|
||||
:footer="null"
|
||||
width="520px"
|
||||
width="420px"
|
||||
class="profile-modal"
|
||||
>
|
||||
<div class="profile-content">
|
||||
@ -93,7 +92,7 @@
|
||||
@change="handleAvatarChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<a-button type="primary" size="small" :loading="avatarUploading">
|
||||
<a-button type="primary" class="lucide-icon-btn" size="small" :loading="avatarUploading">
|
||||
<template #icon><Upload size="14" /></template>
|
||||
{{ userStore.avatar ? '更换头像' : '上传头像' }}
|
||||
</a-button>
|
||||
@ -144,10 +143,8 @@
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">角色</div>
|
||||
<div class="info-value">
|
||||
<a-tag :color="getRoleColor(userStore.userRole)" class="role-tag">
|
||||
{{ userRoleText }}
|
||||
</a-tag>
|
||||
<div class="info-value" :style="{'color': getRoleColor(userStore.userRole) }">
|
||||
{{ userRoleText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item" v-if="userStore.departmentId">
|
||||
@ -315,11 +312,11 @@ const openProfile = async () => {
|
||||
const getRoleColor = (role) => {
|
||||
switch (role) {
|
||||
case 'superadmin':
|
||||
return 'red'
|
||||
return 'var(--color-error-700)'
|
||||
case 'admin':
|
||||
return 'blue'
|
||||
return 'var(--color-primary-500)'
|
||||
case 'user':
|
||||
return 'green'
|
||||
return 'var(--color-success-500)'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
@ -549,14 +546,8 @@ const handleAvatarChange = async (info) => {
|
||||
|
||||
.profile-modal {
|
||||
:deep(.ant-modal-header) {
|
||||
padding: 20px 24px;
|
||||
padding: 40px 24px;
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
@ -622,7 +613,7 @@ const handleAvatarChange = async (info) => {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--gray-50);
|
||||
|
||||
&:last-child {
|
||||
@ -630,9 +621,9 @@ const handleAvatarChange = async (info) => {
|
||||
}
|
||||
|
||||
.info-label {
|
||||
width: 80px;
|
||||
width: 120px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-500);
|
||||
color: var(--gray-600);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@ -649,12 +640,6 @@ const handleAvatarChange = async (info) => {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
<!-- 头部区域 -->
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<h3 class="title">用户管理</h3>
|
||||
<p class="description">管理系统用户,请谨慎操作。删除用户后该用户将无法登录系统。</p>
|
||||
<div class="section-title">用户管理</div>
|
||||
<p class="section-description">管理系统用户,请谨慎操作。删除用户后该用户将无法登录系统。</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showAddUserModal" class="add-btn">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<a-button type="primary" @click="showAddUserModal" class="add-btn lucide-icon-btn">
|
||||
<template #icon><Plus :size="16" /></template>
|
||||
添加用户
|
||||
</a-button>
|
||||
</div>
|
||||
@ -85,9 +85,9 @@
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showEditUserModal(user)"
|
||||
class="action-btn"
|
||||
class="action-btn lucide-icon-btn"
|
||||
>
|
||||
<EditOutlined />
|
||||
<Pencil :size="14" />
|
||||
<span>编辑</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@ -101,9 +101,9 @@
|
||||
user.id === userStore.userId ||
|
||||
(user.role === 'superadmin' && userStore.userRole !== 'superadmin')
|
||||
"
|
||||
class="action-btn"
|
||||
class="action-btn lucide-icon-btn"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@ -238,8 +238,7 @@ import { reactive, onMounted, watch } from 'vue'
|
||||
import { notification, Modal } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { departmentApi } from '@/apis'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { User, UserLock, UserStar } from 'lucide-vue-next'
|
||||
import { Plus, Pencil, Trash2, User, UserLock, UserStar } from 'lucide-vue-next'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const userStore = useUserStore()
|
||||
@ -585,27 +584,6 @@ onMounted(async () => {
|
||||
|
||||
<style lang="less" scoped>
|
||||
.user-management {
|
||||
margin-top: 12px;
|
||||
min-height: 50vh;
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--gray-600);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card secondary">
|
||||
<div class="stat-card secondary clickable" @click="handleFeedbackClick">
|
||||
<div class="stat-icon">
|
||||
<BarChart3 class="icon" />
|
||||
</div>
|
||||
@ -89,6 +89,14 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['open-feedback'])
|
||||
|
||||
// Methods
|
||||
const handleFeedbackClick = () => {
|
||||
emit('open-feedback')
|
||||
}
|
||||
|
||||
// Methods
|
||||
const getSatisfactionClass = () => {
|
||||
const rate = props.basicStats?.feedback_stats?.satisfaction_rate || 0
|
||||
@ -165,6 +173,10 @@ const getSatisfactionClass = () => {
|
||||
}
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.satisfaction-high {
|
||||
.stat-icon {
|
||||
background-color: var(--color-success-50);
|
||||
|
||||
@ -155,7 +155,9 @@ const initToolsChart = () => {
|
||||
|
||||
toolsChart = echarts.init(toolsChartRef.value)
|
||||
|
||||
const data = props.toolStats.most_used_tools.slice(0, 10) // 只显示前10个
|
||||
const data = [...props.toolStats.most_used_tools]
|
||||
.sort((a, b) => a.count - b.count)
|
||||
.slice(0, 10) // 只显示前10个,升序排列让最高的在最上面
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
|
||||
@ -38,8 +38,8 @@ const router = createRouter({
|
||||
meta: { keepAlive: true, requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: ':agent_id',
|
||||
name: 'AgentCompWithId',
|
||||
path: ':thread_id',
|
||||
name: 'AgentCompWithThreadId',
|
||||
component: () => import('../views/AgentView.vue'),
|
||||
meta: { keepAlive: true, requiresAuth: true }
|
||||
}
|
||||
@ -156,27 +156,14 @@ router.beforeEach(async (to, from, next) => {
|
||||
|
||||
// 如果路由需要管理员权限但用户不是管理员
|
||||
if (requiresAdmin && !isAdmin) {
|
||||
// 如果是普通用户,跳转到默认智能体页面
|
||||
// 如果是普通用户,跳转到聊天页空态
|
||||
try {
|
||||
const agentStore = useAgentStore()
|
||||
// 等待 store 初始化完成
|
||||
if (!agentStore.isInitialized) {
|
||||
await agentStore.initialize()
|
||||
}
|
||||
|
||||
const defaultAgent = agentStore.defaultAgent
|
||||
if (defaultAgent && defaultAgent.id) {
|
||||
next(`/agent/${defaultAgent.id}`)
|
||||
} else {
|
||||
// 如果没有默认智能体,可以考虑跳转到第一个可用的智能体,或者一个特定的页面
|
||||
const agentIds = Object.keys(agentStore.agents)
|
||||
if (agentIds.length > 0) {
|
||||
next(`/agent/${agentIds[0]}`)
|
||||
} else {
|
||||
// 没有可用的智能体,跳转到聊天页
|
||||
next('/agent')
|
||||
}
|
||||
}
|
||||
next('/agent')
|
||||
} catch (error) {
|
||||
console.error('获取智能体信息失败:', error)
|
||||
next('/agent')
|
||||
@ -191,12 +178,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (!agentStore.isInitialized) {
|
||||
await agentStore.initialize()
|
||||
}
|
||||
const defaultAgent = agentStore.defaultAgent
|
||||
if (defaultAgent && defaultAgent.id) {
|
||||
next(`/agent/${defaultAgent.id}`)
|
||||
} else {
|
||||
next('/agent')
|
||||
}
|
||||
next('/agent')
|
||||
} catch (error) {
|
||||
console.error('获取智能体信息失败:', error)
|
||||
next('/agent')
|
||||
|
||||
@ -26,7 +26,8 @@ export const useInfoStore = defineStore('info', () => {
|
||||
infoConfig.value.branding || {
|
||||
name: '',
|
||||
title: '',
|
||||
subtitle: ''
|
||||
subtitle: '',
|
||||
subtitles: []
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
<AgentChatComponent
|
||||
ref="chatComponentRef"
|
||||
:single-mode="false"
|
||||
@thread-change="handleThreadChange"
|
||||
>
|
||||
<template #input-actions-left>
|
||||
<button
|
||||
@ -106,61 +107,70 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 从 agentStore 中获取响应式状态
|
||||
const { agents, selectedAgentId, selectedConfigSummary, isLoadingConfig } = storeToRefs(agentStore)
|
||||
const { selectedAgentId, defaultAgentId, selectedConfigSummary, isLoadingConfig } =
|
||||
storeToRefs(agentStore)
|
||||
|
||||
const syncingRouteAgent = ref(false)
|
||||
const syncingRouteThread = ref(false)
|
||||
|
||||
const getRouteAgentId = () => {
|
||||
const value = route.params.agent_id
|
||||
const getRouteThreadId = () => {
|
||||
const value = route.params.thread_id
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
const syncSelectedAgentFromRoute = async () => {
|
||||
const routeAgentId = getRouteAgentId()
|
||||
if (!routeAgentId) return
|
||||
const syncSelectedThreadFromRoute = async () => {
|
||||
const chatComponent = chatComponentRef.value
|
||||
if (!chatComponent?.selectThreadFromRoute) return
|
||||
|
||||
syncingRouteAgent.value = true
|
||||
const threadId = getRouteThreadId()
|
||||
syncingRouteThread.value = true
|
||||
try {
|
||||
if (!agentStore.isInitialized) {
|
||||
await agentStore.initialize()
|
||||
}
|
||||
|
||||
const routeAgentExists = (agents.value || []).some((agent) => agent.id === routeAgentId)
|
||||
if (!routeAgentExists) {
|
||||
if (selectedAgentId.value) {
|
||||
await router.replace({
|
||||
name: 'AgentCompWithId',
|
||||
params: { agent_id: selectedAgentId.value }
|
||||
})
|
||||
if (!threadId) {
|
||||
if (!agentStore.isInitialized) {
|
||||
await agentStore.initialize()
|
||||
}
|
||||
const targetAgentId = defaultAgentId.value
|
||||
if (targetAgentId && selectedAgentId.value !== targetAgentId) {
|
||||
await agentStore.selectAgent(targetAgentId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedAgentId.value !== routeAgentId) {
|
||||
await agentStore.selectAgent(routeAgentId)
|
||||
const ok = await chatComponent.selectThreadFromRoute(threadId)
|
||||
if (threadId && !ok) {
|
||||
await router.replace({ name: 'AgentComp' })
|
||||
}
|
||||
} catch (error) {
|
||||
handleChatError(error, 'load')
|
||||
} finally {
|
||||
syncingRouteAgent.value = false
|
||||
syncingRouteThread.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.agent_id,
|
||||
() => route.params.thread_id,
|
||||
() => {
|
||||
syncSelectedAgentFromRoute()
|
||||
syncSelectedThreadFromRoute()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(selectedAgentId, (newAgentId) => {
|
||||
if (!newAgentId || syncingRouteAgent.value) return
|
||||
const routeAgentId = getRouteAgentId()
|
||||
if (routeAgentId === newAgentId) return
|
||||
router.replace({ name: 'AgentCompWithId', params: { agent_id: newAgentId } })
|
||||
watch(chatComponentRef, (instance) => {
|
||||
if (!instance) return
|
||||
syncSelectedThreadFromRoute()
|
||||
})
|
||||
|
||||
const handleThreadChange = (threadId) => {
|
||||
if (syncingRouteThread.value) return
|
||||
const currentRouteThreadId = getRouteThreadId()
|
||||
const nextThreadId = threadId || ''
|
||||
if (currentRouteThreadId === nextThreadId) return
|
||||
|
||||
if (nextThreadId) {
|
||||
router.replace({ name: 'AgentCompWithThreadId', params: { thread_id: nextThreadId } })
|
||||
} else {
|
||||
router.replace({ name: 'AgentComp' })
|
||||
}
|
||||
}
|
||||
|
||||
const openConfigSidebar = () => {
|
||||
chatUIStore.isConfigSidebarOpen = !chatUIStore.isConfigSidebarOpen
|
||||
}
|
||||
@ -177,7 +187,7 @@ const toggleMoreMenu = (event) => {
|
||||
if (chatUIStore.moreMenuOpen) {
|
||||
// 只在打开时计算位置
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
chatUIStore.openMoreMenu(rect.right - 130, rect.bottom + 8)
|
||||
chatUIStore.openMoreMenu(rect.right - 110, rect.bottom + 8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<!-- 现代化顶部统计栏 -->
|
||||
<div class="modern-stats-header">
|
||||
<StatusBar />
|
||||
<StatsOverviewComponent :basic-stats="basicStats" />
|
||||
<StatsOverviewComponent :basic-stats="basicStats" @open-feedback="handleOpenFeedback" />
|
||||
</div>
|
||||
|
||||
<!-- Grid布局的主要内容区域 -->
|
||||
@ -48,75 +48,6 @@
|
||||
ref="knowledgeStatsRef"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 对话记录 - 占据1x1网格 -->
|
||||
<div class="grid-item conversations">
|
||||
<a-card class="conversations-section" title="对话记录" :loading="loading">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input
|
||||
v-model:value="filters.user_id"
|
||||
placeholder="用户ID"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="状态"
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="deleted">已删除</a-select-option>
|
||||
<a-select-option value="all">全部</a-select-option>
|
||||
</a-select>
|
||||
<a-button size="small" @click="loadConversations" :loading="loading"> 刷新 </a-button>
|
||||
<a-button size="small" @click="feedbackModal.show()"> 反馈详情 </a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="conversationColumns"
|
||||
:data-source="conversations"
|
||||
:loading="loading"
|
||||
:pagination="conversationPagination"
|
||||
@change="handleTableChange"
|
||||
row-key="thread_id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<a
|
||||
@click="handleViewDetail(record)"
|
||||
class="conversation-title"
|
||||
:class="{ loading: loadingDetail }"
|
||||
>{{ record.title || '未命名对话' }}</a
|
||||
>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'" size="small">
|
||||
{{ record.status === 'active' ? '活跃' : '已删除' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'updated_at'">
|
||||
<span class="time-text">{{ formatDate(record.updated_at) }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleViewDetail(record)"
|
||||
:loading="loadingDetail"
|
||||
>
|
||||
详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 反馈模态框 -->
|
||||
@ -128,7 +59,6 @@
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { dashboardApi } from '@/apis/dashboard_api'
|
||||
import dayjs, { parseToShanghai } from '@/utils/time'
|
||||
|
||||
// 导入子组件
|
||||
import StatusBar from '@/components/StatusBar.vue'
|
||||
@ -152,74 +82,13 @@ const allStatsData = ref({
|
||||
agents: null
|
||||
})
|
||||
|
||||
// 过滤器
|
||||
const filters = reactive({
|
||||
user_id: '',
|
||||
agent_id: '',
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// 对话列表
|
||||
const conversations = ref([])
|
||||
const loading = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
|
||||
// 调用统计子组件引用
|
||||
const callStatsRef = ref(null)
|
||||
|
||||
// 分页
|
||||
const conversationPagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 8,
|
||||
total: 0,
|
||||
showSizeChanger: false,
|
||||
showQuickJumper: false,
|
||||
showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const conversationColumns = [
|
||||
{
|
||||
title: '对话标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
width: '80px',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '消息数',
|
||||
dataIndex: 'message_count',
|
||||
key: 'message_count',
|
||||
width: '60px',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: '70px',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: '60px',
|
||||
align: 'center'
|
||||
}
|
||||
]
|
||||
|
||||
// 子组件引用
|
||||
const userStatsRef = ref(null)
|
||||
const toolStatsRef = ref(null)
|
||||
@ -267,72 +136,9 @@ const loadAllStats = async () => {
|
||||
// 保留原有的loadStats函数以兼容旧代码
|
||||
const loadStats = loadAllStats
|
||||
|
||||
// 加载对话列表
|
||||
const loadConversations = async () => {
|
||||
try {
|
||||
const params = {
|
||||
user_id: filters.user_id || undefined,
|
||||
agent_id: filters.agent_id || undefined,
|
||||
status: filters.status,
|
||||
limit: conversationPagination.pageSize,
|
||||
offset: (conversationPagination.current - 1) * conversationPagination.pageSize
|
||||
}
|
||||
|
||||
const response = await dashboardApi.getConversations(params)
|
||||
conversations.value = response
|
||||
// Note: 由于后端没有返回总数,这里暂时设置为当前数据长度
|
||||
conversationPagination.total = response.length
|
||||
} catch (error) {
|
||||
console.error('加载对话列表失败:', error)
|
||||
message.error('加载对话列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 日期格式化
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
const parsed = parseToShanghai(dateString)
|
||||
if (!parsed) return '-'
|
||||
const now = dayjs().tz('Asia/Shanghai')
|
||||
const diffDays = now.startOf('day').diff(parsed.startOf('day'), 'day')
|
||||
|
||||
if (diffDays === 0) {
|
||||
return parsed.format('HH:mm')
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return '昨天'
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}天前`
|
||||
}
|
||||
return parsed.format('MM-DD')
|
||||
}
|
||||
|
||||
// 查看对话详情
|
||||
const handleViewDetail = async (record) => {
|
||||
try {
|
||||
loadingDetail.value = true
|
||||
const detail = await dashboardApi.getConversationDetail(record.thread_id)
|
||||
console.log(detail)
|
||||
} catch (error) {
|
||||
console.error('获取对话详情失败:', error)
|
||||
message.error('获取对话详情失败')
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理过滤器变化
|
||||
const handleFilterChange = () => {
|
||||
conversationPagination.current = 1
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
// 处理表格变化
|
||||
const handleTableChange = (pag) => {
|
||||
conversationPagination.current = pag.current
|
||||
conversationPagination.pageSize = pag.pageSize
|
||||
loadConversations()
|
||||
// 打开反馈详情弹窗
|
||||
const handleOpenFeedback = () => {
|
||||
feedbackModal.value?.show()
|
||||
}
|
||||
|
||||
// 清理函数 - 清理所有子组件的图表实例
|
||||
@ -347,7 +153,6 @@ const cleanupCharts = () => {
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadAllStats()
|
||||
loadConversations()
|
||||
})
|
||||
|
||||
// 组件卸载时清理图表
|
||||
@ -422,17 +227,10 @@ onUnmounted(() => {
|
||||
grid-row: 2 / 3;
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
&.conversations {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 3 / 4;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard 特有的卡片样式
|
||||
.conversations-section,
|
||||
.call-stats-section {
|
||||
background-color: var(--gray-0);
|
||||
border: 1px solid var(--gray-200);
|
||||
@ -510,27 +308,6 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard 特有的对话记录样式
|
||||
.conversations-section {
|
||||
.conversation-title {
|
||||
color: var(--main-500);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-600);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.time-text {
|
||||
color: var(--gray-600);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 调用统计模块样式
|
||||
.call-stats-section {
|
||||
.call-stats-container {
|
||||
@ -617,12 +394,6 @@ onUnmounted(() => {
|
||||
grid-row: 3 / 4;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
&.conversations {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 4 / 5;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -641,8 +412,7 @@ onUnmounted(() => {
|
||||
&.agent-stats,
|
||||
&.user-stats,
|
||||
&.tool-stats,
|
||||
&.knowledge-stats,
|
||||
&.conversations {
|
||||
&.knowledge-stats {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: auto;
|
||||
min-height: 300px;
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
<a-result status="error" :title="error.title" :sub-title="error.message">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="retryLoad">重试</a-button>
|
||||
<a-button :href="faqUrl" target="_blank" rel="noopener noreferrer">常见问题</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
@ -27,29 +28,6 @@
|
||||
/>
|
||||
<span class="logo-text">{{ infoStore.organization.name }}</span>
|
||||
</div>
|
||||
<nav class="nav-links">
|
||||
<router-link
|
||||
to="/agent"
|
||||
class="nav-link"
|
||||
v-if="userStore.isLoggedIn && userStore.isAdmin"
|
||||
>
|
||||
<span>智能体</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/graph"
|
||||
class="nav-link"
|
||||
v-if="userStore.isLoggedIn && userStore.isAdmin"
|
||||
>
|
||||
<span>知识图谱</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/database"
|
||||
class="nav-link"
|
||||
v-if="userStore.isLoggedIn && userStore.isAdmin"
|
||||
>
|
||||
<span>知识库</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<div class="github-link">
|
||||
<a href="https://github.com/xerrors/Yuxi-Know" target="_blank">
|
||||
@ -66,22 +44,41 @@
|
||||
</div>
|
||||
|
||||
<div class="hero-layout">
|
||||
<div class="hero-content">
|
||||
<h1 class="title">{{ infoStore.branding.title }}</h1>
|
||||
<p class="subtitle">{{ infoStore.branding.subtitle }}</p>
|
||||
<div class="hero-content reveal-up">
|
||||
<p v-if="typedBadge" class="hero-badge" :class="{ typing: isBadgeTyping }">
|
||||
<template v-if="badgeParts.number">
|
||||
<span>{{ badgeParts.prefix }}</span>
|
||||
<a
|
||||
class="hero-badge-link"
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="hero-badge-number">{{ badgeParts.number }}</span>
|
||||
</a>
|
||||
<span>{{ badgeParts.suffix }}</span>
|
||||
</template>
|
||||
<template v-else>{{ typedBadge }}</template>
|
||||
</p>
|
||||
<h1 class="title reveal-up delay-1">{{ infoStore.branding.title }}</h1>
|
||||
<Transition name="subtitle-switch" mode="out-in">
|
||||
<p v-if="currentSubtitle" class="subtitle" :key="currentSubtitle">{{ currentSubtitle }}</p>
|
||||
</Transition>
|
||||
<!-- <p class="description">{{ infoStore.branding.description }}</p> -->
|
||||
<div class="hero-actions">
|
||||
<button class="button-base primary" @click="goToChat">开始对话</button>
|
||||
<a
|
||||
class="button-base secondary"
|
||||
href="https://xerrors.github.io/Yuxi-Know/"
|
||||
target="_blank"
|
||||
<button class="button-base primary" @click="goToChat">开始体验</button>
|
||||
<a class="doc-text-link" href="https://xerrors.github.io/Yuxi-Know/" target="_blank"
|
||||
>查看文档</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insight-panel" v-if="featureCards.length">
|
||||
<div class="stat-card" v-for="card in featureCards" :key="card.label">
|
||||
<div
|
||||
class="stat-card"
|
||||
v-for="(card, index) in featureCards"
|
||||
:key="card.label"
|
||||
:style="{ '--card-stagger': `${index}` }"
|
||||
>
|
||||
<div class="stat-headline">
|
||||
<span class="stat-icon" v-if="card.icon">
|
||||
<component :is="card.icon" />
|
||||
@ -128,14 +125,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useInfoStore } from '@/stores/info'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { healthApi } from '@/apis/system_api'
|
||||
import { Result, Button } from 'ant-design-vue'
|
||||
import UserInfoComponent from '@/components/UserInfoComponent.vue'
|
||||
import ProjectOverview from '@/components/ProjectOverview.vue'
|
||||
import {
|
||||
@ -150,18 +145,176 @@ import {
|
||||
ShieldCheck
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const AResult = Result
|
||||
const AButton = Button
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const infoStore = useInfoStore()
|
||||
const agentStore = useAgentStore()
|
||||
const themeStore = useThemeStore()
|
||||
const repoUrl = 'https://github.com/xerrors/Yuxi-Know'
|
||||
const faqUrl = 'https://xerrors.github.io/Yuxi-Know/latest/changelog/faq.html'
|
||||
|
||||
// 加载状态
|
||||
const isLoading = ref(true)
|
||||
const error = ref(null)
|
||||
const typedBadge = ref('')
|
||||
const isBadgeTyping = ref(false)
|
||||
let badgeTimer = null
|
||||
let subtitleTimer = null
|
||||
let starsFetchController = null
|
||||
|
||||
const GITHUB_REPO_API = 'https://api.github.com/repos/xerrors/Yuxi-Know'
|
||||
const GITHUB_STARS_TIMEOUT = 3000
|
||||
|
||||
const formatStars = (count) => {
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
return ''
|
||||
}
|
||||
return `${count}`
|
||||
}
|
||||
|
||||
const subtitleIndex = ref(0)
|
||||
|
||||
const subtitleOptions = computed(() => {
|
||||
const subtitles = infoStore.branding?.subtitles
|
||||
if (Array.isArray(subtitles)) {
|
||||
const list = subtitles
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
if (list.length) {
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = (infoStore.branding?.subtitle || '').trim()
|
||||
return fallback ? [fallback] : []
|
||||
})
|
||||
|
||||
const currentSubtitle = computed(() => subtitleOptions.value[subtitleIndex.value] || '')
|
||||
const badgeParts = computed(() => {
|
||||
const text = typedBadge.value || ''
|
||||
const match = text.match(/^(.*?)(\d[\d,]*\+?)(\s+GitHub Stars.*)?$/)
|
||||
if (!match) {
|
||||
return {
|
||||
prefix: text,
|
||||
number: '',
|
||||
suffix: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: match[1] || '',
|
||||
number: match[2] || '',
|
||||
suffix: match[3] || ''
|
||||
}
|
||||
})
|
||||
|
||||
const stopSubtitleCarousel = () => {
|
||||
if (subtitleTimer) {
|
||||
clearInterval(subtitleTimer)
|
||||
subtitleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const startSubtitleCarousel = () => {
|
||||
stopSubtitleCarousel()
|
||||
subtitleIndex.value = 0
|
||||
|
||||
if (subtitleOptions.value.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
subtitleTimer = setInterval(() => {
|
||||
subtitleIndex.value = (subtitleIndex.value + 1) % subtitleOptions.value.length
|
||||
}, 2800)
|
||||
}
|
||||
|
||||
const stopStarsFetch = () => {
|
||||
if (starsFetchController) {
|
||||
starsFetchController.abort()
|
||||
starsFetchController = null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchGithubStars = async () => {
|
||||
stopStarsFetch()
|
||||
const controller = new AbortController()
|
||||
starsFetchController = controller
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, GITHUB_STARS_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(GITHUB_REPO_API, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const stars = Number(data?.stargazers_count)
|
||||
return Number.isFinite(stars) && stars > 0 ? stars : null
|
||||
} catch (e) {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (starsFetchController === controller) {
|
||||
starsFetchController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getHeroBadgeText = (starsCount = null) => {
|
||||
const realtimeStars = formatStars(starsCount)
|
||||
if (realtimeStars) {
|
||||
return `已获得 ${realtimeStars} GitHub Stars`
|
||||
}
|
||||
|
||||
const features = Array.isArray(infoStore.features) ? infoStore.features : []
|
||||
const starFeature = features.find((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return /star/i.test(item)
|
||||
}
|
||||
|
||||
return /star|github/i.test(item?.label || '') || /stars|github/i.test(item?.icon || '')
|
||||
})
|
||||
|
||||
if (!starFeature) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const starValue =
|
||||
typeof starFeature === 'string'
|
||||
? ''
|
||||
: (starFeature?.value || '').toString().trim()
|
||||
|
||||
return starValue ? `已获得 ${starValue} GitHub Stars` : '已获得 GitHub Stars'
|
||||
}
|
||||
|
||||
const stopBadgeTyping = () => {
|
||||
if (badgeTimer) {
|
||||
clearInterval(badgeTimer)
|
||||
badgeTimer = null
|
||||
}
|
||||
isBadgeTyping.value = false
|
||||
}
|
||||
|
||||
const startBadgeTyping = (starsCount = null) => {
|
||||
stopBadgeTyping()
|
||||
const text = getHeroBadgeText(starsCount)
|
||||
typedBadge.value = ''
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
let index = 0
|
||||
isBadgeTyping.value = true
|
||||
badgeTimer = setInterval(() => {
|
||||
index += 1
|
||||
typedBadge.value = text.slice(0, index)
|
||||
if (index >= text.length) {
|
||||
stopBadgeTyping()
|
||||
}
|
||||
}, 45)
|
||||
}
|
||||
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
@ -187,8 +340,15 @@ const loadData = async () => {
|
||||
await checkHealth()
|
||||
// 健康检查通过后加载配置
|
||||
await infoStore.loadInfoConfig()
|
||||
startSubtitleCarousel()
|
||||
const starsCount = await fetchGithubStars()
|
||||
startBadgeTyping(starsCount)
|
||||
} catch (e) {
|
||||
console.error('加载失败:', e)
|
||||
stopBadgeTyping()
|
||||
stopSubtitleCarousel()
|
||||
stopStarsFetch()
|
||||
typedBadge.value = ''
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@ -235,6 +395,12 @@ onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopBadgeTyping()
|
||||
stopSubtitleCarousel()
|
||||
stopStarsFetch()
|
||||
})
|
||||
|
||||
const iconKey = (value) => (typeof value === 'string' ? value.toLowerCase() : '')
|
||||
|
||||
// region icon_mapping
|
||||
@ -485,6 +651,57 @@ const actionLinks = computed(() => {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.reveal-up {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
animation: revealUp 0.7s ease forwards;
|
||||
}
|
||||
|
||||
.reveal-up.delay-1 {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
color: var(--main-600);
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-badge-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hero-badge-number {
|
||||
color: var(--main-700);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--main-500);
|
||||
text-underline-offset: 0.15em;
|
||||
text-decoration-thickness: 1.5px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
text-decoration-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-badge-link:hover .hero-badge-number {
|
||||
color: var(--main-800);
|
||||
text-decoration-color: var(--main-700);
|
||||
}
|
||||
|
||||
.hero-badge.typing::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 1em;
|
||||
margin-left: 6px;
|
||||
background: var(--main-600);
|
||||
vertical-align: -0.1em;
|
||||
animation: caretBlink 0.8s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(2.5rem, 4vw, 4rem);
|
||||
font-weight: 800;
|
||||
@ -509,6 +726,21 @@ const actionLinks = computed(() => {
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
min-height: calc(1.4em * 1.3);
|
||||
}
|
||||
|
||||
.subtitle-switch-enter-active,
|
||||
.subtitle-switch-leave-active {
|
||||
transition:
|
||||
opacity 0.32s ease,
|
||||
transform 0.32s ease;
|
||||
}
|
||||
|
||||
.subtitle-switch-enter-from,
|
||||
.subtitle-switch-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(7px);
|
||||
}
|
||||
|
||||
.description {
|
||||
@ -523,6 +755,22 @@ const actionLinks = computed(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.doc-text-link {
|
||||
color: var(--main-700);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed var(--main-300);
|
||||
padding-bottom: 0.15rem;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-800);
|
||||
border-color: var(--main-500);
|
||||
}
|
||||
}
|
||||
|
||||
.button-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -543,10 +791,27 @@ const actionLinks = computed(() => {
|
||||
background: linear-gradient(135deg, var(--main-600), var(--main-500));
|
||||
color: var(--gray-0);
|
||||
border-color: transparent;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, var(--main-700), var(--main-600));
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(circle, rgba(36, 131, 154, 0.35), transparent 68%);
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.button-base.secondary {
|
||||
@ -575,6 +840,10 @@ const actionLinks = computed(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
animation: cardRise 0.55s ease forwards;
|
||||
animation-delay: calc(var(--card-stagger, 0) * 90ms + 200ms);
|
||||
}
|
||||
|
||||
.stat-headline {
|
||||
@ -791,6 +1060,71 @@ const actionLinks = computed(() => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes revealUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardRise {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes caretBlink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(:root.dark) {
|
||||
.hero-badge-number {
|
||||
color: var(--main-200);
|
||||
text-decoration-color: var(--main-300);
|
||||
}
|
||||
|
||||
.hero-badge-link:hover .hero-badge-number {
|
||||
color: var(--main-100);
|
||||
text-decoration-color: var(--main-100);
|
||||
}
|
||||
|
||||
.doc-text-link {
|
||||
color: var(--main-300);
|
||||
border-bottom-color: var(--main-500);
|
||||
|
||||
&:hover {
|
||||
color: var(--main-200);
|
||||
border-bottom-color: var(--main-300);
|
||||
}
|
||||
}
|
||||
|
||||
.button-base.primary::after {
|
||||
background: radial-gradient(circle, rgba(130, 195, 214, 0.28), transparent 72%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-up,
|
||||
.stat-card,
|
||||
.hero-badge.typing::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.reveal-up,
|
||||
.stat-card {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.subtitle-switch-enter-active,
|
||||
.subtitle-switch-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.glass-header {
|
||||
padding: 0.8rem 1.25rem;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user