chore: import upstream snapshot with attribution
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:26 +08:00
commit 1443d3fdf9
732 changed files with 196602 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.eslintcache
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{vue,js,mjs,jsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
])
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>语析 - Knowledge Management</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
{
"name": "yuxi-web",
"version": "0.7.1.beta1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"server": "vite serve --host",
"server:prod": "vite serve --host --port 8080",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@antv/g6": "^5.1.1",
"@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0",
"@vscode/markdown-it-katex": "^1.1.2",
"@vueuse/core": "^14.3.0",
"ant-design-vue": "^4.2.6",
"d3": "^7.9.0",
"dayjs": "^1.11.20",
"dompurify": "^3.4.11",
"echarts": "^6.0.0",
"graphology": "^0.26.0",
"graphology-generators": "^0.11.2",
"highlight.js": "^11.11.1",
"js-yaml": "^4.2.0",
"katex": "^0.16.45",
"less": "^4.6.4",
"lucide-vue-next": "^1.0.0",
"markdown-it": "^14.2.0",
"markdown-it-task-lists": "^2.1.1",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"shiki": "^4.0.2",
"sigma": "^3.0.3",
"vue": "^3.5.34",
"vue-router": "^5.0.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-prettier": "^10.2.0",
"esbuild": "0.28.1",
"eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"vite": "^8.0.16",
"vue-eslint-parser": "^10.4.0"
},
"pnpm": {
"overrides": {
"brace-expansion": "5.0.6",
"esbuild": "0.28.1",
"eslint-scope": "9.1.2",
"flatted": "3.4.2",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"undici": "6.24.0",
"yaml": "2.8.3"
},
"auditConfig": {}
},
"packageManager": "pnpm@10.11.0"
}
+4249
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="128" height="128" style="enable-background:new 0 0 128 128;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M63.79,8.64C1.48,8.64,0,78.5,0,92.33c0,13.83,28.56,25.03,63.79,25.03 c35.24,0,63.79-11.21,63.79-25.03C127.58,78.5,126.11,8.64,63.79,8.64z" style="fill:#FCC21B;"/>
<path d="M63.91,104.82c-3.43,0-6.87-0.43-10.25-1.31c-1.6-0.42-2.56-2.06-2.15-3.66 c0.42-1.6,2.06-2.56,3.66-2.14c11.65,3.04,24.21-0.21,32.78-8.48c1.19-1.15,3.09-1.12,4.24,0.08c1.15,1.19,1.12,3.09-0.08,4.24 C84.54,100.85,74.32,104.82,63.91,104.82z" style="fill:#2F2F2F;"/>
<path d="M55.53,67.26c-0.01,0.01-0.02,0.02-0.02,0.02C55.51,67.27,55.52,67.26,55.53,67.26z" style="fill:#2F2F2F;"/>
<g>
<path d="M98.21,41.34c-13.36,0-15.15,2.03-21.4,3.36C70.56,46.02,64,46.02,64,46.02s-6.56,0-12.81-1.33 c-6.25-1.33-8.05-3.36-21.4-3.36c-13.36,0-29.37,2.89-29.37,2.89v8.51c0,0,3.59,0.47,3.91,3.75c0.16,1.33-3.12,28.35,23.51,28.35 c18.9,0,26.87-11.33,29.45-20.54c1.17-4.37,2.19-9.37,6.72-9.37c4.53,0,5.55,5,6.72,9.37c2.58,9.22,10.54,20.54,29.45,20.54 c26.63,0,23.35-27.03,23.51-28.35c0.31-3.28,3.91-3.75,3.91-3.75v-8.51C127.58,44.23,111.57,41.34,98.21,41.34z" style="fill:#2F2F2F;"/>
<path d="M95.94,45.05c-6.62,0.23-11.65,1.31-11.65,1.31c-9.84,2.06-10.55,8.14-9.93,12.97 c0.8,6.07,3.29,13.75,10.04,18.49c0.53,0.38,1.76,0.79,2.35-0.77c0,0-0.02,0.11,0,0c2.22-10.48,5.52-20.14,10.78-29.89l0,0 C98.14,45.37,96.71,45.02,95.94,45.05z" style="fill:#FFFFFF;"/>
<path d="M31.06,45.02c-4.27-0.09-9.11,0.19-13.65,1.34c-5.1,1.28-7.07,3.85-7.6,9.39 c-0.53,5.43-1.13,19.27,8.73,24.46c0.57,0.3,1.83,0.5,2.44-0.91l0,0C24,66.21,25.61,60.13,32.54,47.22l0,0 C33.11,45.49,31.83,45.03,31.06,45.02z" style="fill:#FFFFFF;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

@@ -0,0 +1,145 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>隐私协议</title>
<style>
:root {
--bg: #f5f7fb;
--card: #ffffff;
--text: #1f2937;
--subtle: #6b7280;
--line: #e5e7eb;
--brand: #2563eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.75;
}
.container {
max-width: 960px;
margin: 40px auto;
padding: 0 20px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 28px;
}
h1 {
margin: 0 0 8px;
font-size: 28px;
}
.meta {
color: var(--subtle);
font-size: 14px;
margin-bottom: 24px;
}
h2 {
margin: 24px 0 10px;
font-size: 20px;
color: #111827;
}
p {
margin: 0 0 12px;
}
ul {
margin: 0 0 12px;
padding-left: 20px;
}
.notice {
margin-top: 24px;
padding: 14px 16px;
border-left: 3px solid var(--brand);
background: #eff6ff;
color: #1e3a8a;
}
a {
color: var(--brand);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<main class="container">
<article class="card">
<h1>隐私协议</h1>
<div class="meta">适用组织:{{ORG_NAME}} | 生效日期:{{EFFECTIVE_DATE}}</div>
<p>
{{PRODUCT_NAME}} 非常重视您的个人信息与隐私保护。我们将依据适用法律法规,采取合理措施保障您的个人信息安全。
</p>
<h2>一、我们收集的信息</h2>
<ul>
<li>账号信息:例如用户 ID、手机号等您主动提供的信息。</li>
<li>使用信息:例如访问时间、操作日志、设备信息等系统运行所需信息。</li>
<li>业务数据:您在使用平台过程中上传或生成的数据内容。</li>
</ul>
<h2>二、信息使用目的</h2>
<ul>
<li>用于提供、维护和优化平台服务。</li>
<li>用于账号安全管理、风险识别和问题排查。</li>
<li>用于履行法律法规规定的义务。</li>
</ul>
<h2>三、信息共享与披露</h2>
<p>
除法律法规另有规定,或获得您的明确授权外,我们不会向第三方出售您的个人信息。
在确有必要共享时,我们会要求接收方遵守相应的数据保护义务。
</p>
<h2>四、信息存储与保护</h2>
<p>
我们采取访问控制、加密存储、日志审计等安全措施保护您的信息。
但请理解,互联网环境并非绝对安全,我们将持续提升安全能力。
</p>
<h2>五、您的权利</h2>
<ul>
<li>您有权访问、更正、删除您的相关信息。</li>
<li>您有权撤回授权并申请注销账号(法律法规另有规定除外)。</li>
<li>您有权就隐私问题向我们提出咨询和投诉。</li>
</ul>
<h2>六、协议更新</h2>
<p>
本隐私协议可能根据法律法规及业务变化进行更新。更新后将通过平台公告或页面提示方式发布。
</p>
<h2>七、联系我们</h2>
<p>
如您对本隐私协议有疑问,可通过平台公开联系方式与我们联系。
</p>
<div class="notice">
提示:本文件为模板协议,请在上线前替换 {{ORG_NAME}}、{{PRODUCT_NAME}}、{{EFFECTIVE_DATE}} 等占位内容,并由法务审核。
</div>
</article>
</main>
</body>
</html>
@@ -0,0 +1,142 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用户协议</title>
<style>
:root {
--bg: #f5f7fb;
--card: #ffffff;
--text: #1f2937;
--subtle: #6b7280;
--line: #e5e7eb;
--brand: #2563eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.75;
}
.container {
max-width: 960px;
margin: 40px auto;
padding: 0 20px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 28px;
}
h1 {
margin: 0 0 8px;
font-size: 28px;
}
.meta {
color: var(--subtle);
font-size: 14px;
margin-bottom: 24px;
}
h2 {
margin: 24px 0 10px;
font-size: 20px;
color: #111827;
}
p {
margin: 0 0 12px;
}
ul {
margin: 0 0 12px;
padding-left: 20px;
}
.notice {
margin-top: 24px;
padding: 14px 16px;
border-left: 3px solid var(--brand);
background: #eff6ff;
color: #1e3a8a;
}
a {
color: var(--brand);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<main class="container">
<article class="card">
<h1>用户协议</h1>
<div class="meta">适用组织:{{ORG_NAME}} | 生效日期:{{EFFECTIVE_DATE}}</div>
<p>
欢迎使用 {{PRODUCT_NAME}}。在使用本平台提供的服务前,请仔细阅读并充分理解本协议。
您在登录、注册或继续使用本平台服务时,即视为已阅读并同意本协议全部内容。
</p>
<h2>一、服务说明</h2>
<p>
本平台提供与知识库管理、知识图谱、智能问答等相关的技术服务。平台有权在合理范围内调整服务内容、服务方式或功能入口。
</p>
<h2>二、账号与安全</h2>
<ul>
<li>您应提供真实、准确、完整的注册信息,并及时更新。</li>
<li>您应妥善保管账号与密码,并对账号下的全部行为承担责任。</li>
<li>若发现账号存在异常使用,请立即通知平台处理。</li>
</ul>
<h2>三、使用规范</h2>
<ul>
<li>不得利用平台从事违法违规活动,不得侵害他人合法权益。</li>
<li>不得上传、发布或传播含有恶意代码、侵权或其他非法内容的信息。</li>
<li>不得干扰平台正常运行或绕过平台安全机制。</li>
</ul>
<h2>四、知识产权</h2>
<p>
平台相关软件、技术、界面及文档的知识产权归平台或其权利人所有。未经许可,任何人不得擅自复制、修改、传播或用于其他商业用途。
</p>
<h2>五、责任限制</h2>
<p>
在法律允许范围内,平台对因不可抗力、网络故障、第三方原因等导致的服务中断或数据异常不承担责任。
</p>
<h2>六、协议变更</h2>
<p>
平台可根据业务发展和法律法规要求更新本协议。更新后的协议在发布后生效。若您继续使用服务,视为接受更新后的协议。
</p>
<h2>七、联系我们</h2>
<p>
如您对本协议有任何疑问,请通过平台公布的联系方式与我们联系。
</p>
<div class="notice">
提示:本文件为模板协议,请在上线前替换 {{ORG_NAME}}、{{PRODUCT_NAME}}、{{EFFECTIVE_DATE}} 等占位内容,并由法务审核。
</div>
</article>
</main>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { useAgentStore } from '@/stores/agent'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { onMounted } from 'vue'
const agentStore = useAgentStore()
const userStore = useUserStore()
const themeStore = useThemeStore()
onMounted(async () => {
if (userStore.isLoggedIn) {
await agentStore.initialize()
}
})
</script>
<template>
<a-config-provider :theme="themeStore.currentTheme" :locale="zhCN">
<router-view />
</a-config-provider>
</template>
+383
View File
@@ -0,0 +1,383 @@
import { apiGet, apiPost, apiDelete, apiPut, apiRequest } from './base'
import { useUserStore } from '@/stores/user'
/**
* 智能体API模块
* 包含智能体管理、聊天、配置等功能
* 权限要求: 任何已登录用户(普通用户、管理员、超级管理员)
*/
// =============================================================================
// === 智能体聊天分组 ===
// =============================================================================
export const agentApi = {
/**
* 简单聊天调用(非流式)
* @param {string} query - 查询内容
* @returns {Promise} - 聊天响应
*/
simpleCall: (query) => apiPost('/api/chat/call', { query }),
/**
* 生成对话标题
* @param {string} query - 查询内容
* @param {Object} modelSpec - 模型配置
* @returns {Promise<string>} - 生成的标题
*/
generateTitle: async (query, modelSpec) => {
const response = await apiPost('/api/chat/call', {
query: `根据以下对话内容生成一个简短的标题(最多30个字符,中英文均可),不要包含 markdown 标记:\n\n${query.slice(0, 2000)}`,
meta: { model_spec: modelSpec }
})
return response.response
},
/**
* 获取智能体列表
* @returns {Promise} - 智能体列表
*/
getAgents: ({ includeSubagents = false } = {}) => {
const params = new URLSearchParams()
if (includeSubagents) params.set('include_subagents', 'true')
const query = params.toString()
return apiGet(query ? `/api/agent?${query}` : '/api/agent')
},
getAgentBackends: () => apiGet('/api/agent/backends'),
/**
* 获取单个智能体详情
* @param {string} agentId - 智能体ID
* @returns {Promise} - 智能体详情
*/
getAgentDetail: (agentId) => apiGet(`/api/agent/${agentId}`),
/**
* 获取智能体历史消息
* @param {string} agentId - 智能体ID
* @param {string} threadId - 会话ID
* @returns {Promise} - 历史消息
*/
getAgentHistory: (threadId) => apiGet(`/api/chat/thread/${threadId}/history`),
/**
* 获取指定会话的 AgentState
* @param {string} agentId - 智能体ID
* @param {string} threadId - 会话ID
* @returns {Promise} - AgentState
*/
getAgentState: (threadId, { includeMessages = false } = {}) =>
apiGet(`/api/chat/thread/${threadId}/state${includeMessages ? '?include_messages=true' : ''}`),
/**
* Submit feedback for a message
* @param {number} messageId - Message ID
* @param {string} rating - 'like' or 'dislike'
* @param {string|null} reason - Optional reason for dislike
* @returns {Promise} - Feedback response
*/
submitMessageFeedback: (messageId, rating, reason = null) =>
apiPost(`/api/chat/message/${messageId}/feedback`, { rating, reason }),
/**
* Get feedback status for a message
* @param {number} messageId - Message ID
* @returns {Promise} - Feedback status
*/
getMessageFeedback: (messageId) => apiGet(`/api/chat/message/${messageId}/feedback`),
createAgent: (payload) => apiPost('/api/agent', payload),
updateAgent: (agentId, payload) => apiPut(`/api/agent/${agentId}`, payload),
deleteAgent: (agentId) => apiDelete(`/api/agent/${agentId}`),
/**
* 创建异步运行任务(Run
* @param {Object} data - run 请求体
* @returns {Promise<Object>}
*/
createAgentRun: (data) =>
apiPost('/api/agent/runs', {
query: data.query,
agent_slug: data.agent_slug,
thread_id: data.thread_id,
meta: data.meta || {},
image_content: data.image_content || null,
model_spec: data.model_spec || null,
resume: data.resume ?? null,
created_by_run_id: data.created_by_run_id || null
}),
/**
* 获取 Run 状态
* @param {string} runId - run ID
* @returns {Promise<Object>}
*/
getAgentRun: (runId) => apiGet(`/api/agent/runs/${runId}`),
/**
* 取消 Run
* @param {string} runId - run ID
* @returns {Promise<Object>}
*/
cancelAgentRun: (runId) => apiPost(`/api/agent/runs/${runId}/cancel`, {}),
/**
* 获取线程活跃 Run
* @param {string} threadId - 线程ID
* @returns {Promise<Object>}
*/
getThreadActiveRun: (threadId) => apiGet(`/api/agent/thread/${threadId}/active_run`),
/**
* 打开 Run 事件 SSE 连接(调用方负责关闭)
* @param {string} runId - run ID
* @param {string} afterSeq - 起始 seq/cursor
* @param {Object} options - { signal, verbose }
* @returns {Promise<Response>}
*/
streamAgentRunEvents: (runId, afterSeq = '0-0', options = {}) => {
const { signal, verbose = false } = options
const headers = {
...useUserStore().getAuthHeaders()
}
const cursor = String(afterSeq || '0-0')
if (cursor && cursor !== '0-0') {
headers['Last-Event-ID'] = cursor
}
const params = new URLSearchParams({ verbose: String(verbose) })
return fetch(`/api/agent/runs/${runId}/events?${params.toString()}`, {
method: 'GET',
headers,
signal
})
}
}
// =============================================================================
// === 多模态图片支持分组 ===
// =============================================================================
export const multimodalApi = {
/**
* 上传图片并获取base64编码
* @param {File} file - 图片文件
* @returns {Promise} - 上传结果
*/
uploadImage: (file) => {
const formData = new FormData()
formData.append('file', file)
return apiRequest(
'/api/chat/image/upload',
{
method: 'POST',
body: formData
},
true
)
}
}
// =============================================================================
// === 对话线程分组 ===
// =============================================================================
export const threadApi = {
/**
* 获取对话线程列表
* @param {string | null | undefined} agentId - 智能体ID,可选;不传时返回全部智能体对话
* @param {number} limit - 返回数量限制,默认100
* @param {number} offset - 偏移量,默认0
* @returns {Promise} - 对话线程列表
*/
getThreads: (agentId = null, limit = 100, offset = 0) => {
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset)
})
if (agentId) {
params.set('agent_id', agentId)
}
const url = `/api/chat/threads?${params.toString()}`
return apiGet(url)
},
/**
* 搜索历史对话
* @param {string} query - 搜索关键词
* @param {Object} options - 搜索选项
* @param {string | null | undefined} options.agentId - 智能体ID,可选
* @param {number} options.limit - 返回数量限制
* @param {number} options.offset - 偏移量
* @returns {Promise} - 搜索结果
*/
searchThreads: (query, { agentId = null, limit = 20, offset = 0 } = {}) => {
const params = new URLSearchParams({
q: query,
limit: String(limit),
offset: String(offset)
})
if (agentId) {
params.set('agent_id', agentId)
}
return apiGet(`/api/chat/threads/search?${params.toString()}`)
},
/**
* 创建新对话线程
* @param {string} agentId - 智能体ID
* @param {string} title - 对话标题
* @param {Object} metadata - 元数据
* @returns {Promise} - 创建结果
*/
createThread: (agentId, title, metadata) =>
apiPost('/api/chat/thread', {
agent_id: agentId,
title: title || '新的对话',
metadata: metadata || {}
}),
/**
* 更新对话线程
* @param {string} threadId - 对话线程ID
* @param {string} title - 对话标题
* @param {boolean} is_pinned - 是否置顶
* @returns {Promise} - 更新结果
*/
updateThread: (threadId, title, is_pinned) =>
apiPut(`/api/chat/thread/${threadId}`, {
title,
is_pinned
}),
/**
* 删除对话线程
* @param {string} threadId - 对话线程ID
* @returns {Promise} - 删除结果
*/
deleteThread: (threadId) => apiDelete(`/api/chat/thread/${threadId}`),
/**
* 获取线程附件列表
* @param {string} threadId - 对话线程ID
* @returns {Promise}
*/
getThreadAttachments: (threadId) => apiGet(`/api/chat/thread/${threadId}/attachments`),
/**
* 列出线程文件(目录)
* @param {string} threadId
* @param {string} path
* @param {boolean} recursive
* @returns {Promise}
*/
listThreadFiles: (threadId, path = '/home/gem/user-data', recursive = false) =>
apiGet(
`/api/chat/thread/${threadId}/files?path=${encodeURIComponent(path)}&recursive=${recursive}`
),
/**
* 读取线程文本文件内容(分页)
* @param {string} threadId
* @param {string} path
* @param {number} offset
* @param {number} limit
* @returns {Promise}
*/
readThreadFile: (threadId, path, offset = 0, limit = 2000) =>
apiGet(
`/api/chat/thread/${threadId}/files/content?path=${encodeURIComponent(path)}&offset=${offset}&limit=${limit}`
),
/**
* 获取线程文件下载/预览 URL
* @param {string} threadId
* @param {string} path
* @param {boolean} download
* @returns {string}
*/
getThreadArtifactUrl: (threadId, path, download = false) => {
const encodedPath = path
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/')
const query = download ? '?download=true' : ''
return `/api/chat/thread/${threadId}/artifacts/${encodedPath}${query}`
},
/**
* 下载线程文件(带鉴权)
* @param {string} threadId
* @param {string} path
* @returns {Promise<Response>}
*/
downloadThreadArtifact: (threadId, path) =>
apiGet(threadApi.getThreadArtifactUrl(threadId, path, true), {}, true, 'blob'),
/**
* 保存交付物到 workspace/saved_artifacts
* @param {string} threadId
* @param {string} path
* @returns {Promise}
*/
saveThreadArtifactToWorkspace: (threadId, path) =>
apiPost(`/api/chat/thread/${threadId}/artifacts/save`, { path }),
/**
* 上传临时附件
* @param {File} file
* @returns {Promise}
*/
uploadTmpAttachment: (file) => {
const formData = new FormData()
formData.append('file', file)
return apiRequest('/api/chat/attachments/tmp', {
method: 'POST',
body: formData
})
},
/**
* 解析临时附件
* @param {Object} payload
* @returns {Promise}
*/
parseTmpAttachment: (payload) => apiPost('/api/chat/attachments/tmp/parse', payload),
/**
* 确认添加临时附件到线程
* @param {string} threadId
* @param {Array} attachments
* @returns {Promise}
*/
confirmTmpThreadAttachments: (threadId, attachments) =>
apiPost(`/api/chat/thread/${threadId}/attachments/confirm`, { attachments }),
/**
* 上传附件
* @param {string} threadId
* @param {File} file
* @returns {Promise}
*/
uploadThreadAttachment: (threadId, file) => {
const formData = new FormData()
formData.append('file', file)
return apiRequest(`/api/chat/thread/${threadId}/attachments`, {
method: 'POST',
body: formData
})
},
/**
* 删除附件
* @param {string} threadId
* @param {string} fileId
* @returns {Promise}
*/
deleteThreadAttachment: (threadId, fileId) =>
apiDelete(`/api/chat/thread/${threadId}/attachments/${fileId}`)
}
+9
View File
@@ -0,0 +1,9 @@
import { apiGet, apiPut } from './base'
const AGENT_ENV_PATH = '/api/user/agent-env'
export const agentEnvApi = {
get: () => apiGet(AGENT_ENV_PATH),
update: (env) => apiPut(AGENT_ENV_PATH, { env: env || {} })
}
+17
View File
@@ -0,0 +1,17 @@
import { apiGet, apiPost, apiPut, apiDelete } from './base'
const API_KEY_BASE_PATH = '/api/user/apikey'
export const apikeyApi = {
list: (skip = 0, limit = 100) => apiGet(`${API_KEY_BASE_PATH}/`, { params: { skip, limit } }),
create: (data) => apiPost(`${API_KEY_BASE_PATH}/`, data),
get: (id) => apiGet(`${API_KEY_BASE_PATH}/${id}`),
update: (id, data) => apiPut(`${API_KEY_BASE_PATH}/${id}`, data),
delete: (id) => apiDelete(`${API_KEY_BASE_PATH}/${id}`),
regenerate: (id) => apiPost(`${API_KEY_BASE_PATH}/${id}/regenerate`)
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 认证相关 API
*/
import { apiAdminGet, apiGet, apiPost } from './base'
async function parseErrorDetail(response, fallbackMessage) {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const error = await response.json()
return error?.detail || fallbackMessage
}
const text = (await response.text()).trim()
return text || fallbackMessage
}
/**
* 获取 OIDC 配置
* @returns {Promise<{enabled: boolean, provider_name?: string}>}
*/
async function getOIDCConfig() {
const response = await fetch('/api/auth/oidc/config')
if (!response.ok) {
throw new Error('获取 OIDC 配置失败')
}
return response.json()
}
/**
* 获取 OIDC 登录 URL
* @param {string} redirectPath - 登录后的重定向路径
* @returns {Promise<{login_url: string}>}
*/
async function getOIDCLoginUrl(redirectPath = '/') {
const params = new URLSearchParams({ redirect_path: redirectPath })
const response = await fetch(`/api/auth/oidc/login-url?${params}`)
if (!response.ok) {
const detail = await parseErrorDetail(response, '获取 OIDC 登录地址失败')
throw new Error(detail)
}
return response.json()
}
/**
* 使用一次性 code 交换 OIDC 登录结果
* @param {string} code - 一次性登录 code
* @returns {Promise<{
* access_token: string,
* token_type: string,
* user_id: number,
* username: string,
* uid: string,
* phone_number: string | null,
* avatar: string | null,
* role: string,
* department_id: number | null,
* department_name: string | null
* }>}
*/
async function getUserAccessOptions() {
return apiAdminGet('/api/auth/users/access-options')
}
async function exchangeOIDCCode(code) {
const response = await fetch('/api/auth/oidc/exchange-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
})
if (!response.ok) {
const detail = await parseErrorDetail(response, 'OIDC 登录失败')
throw new Error(detail)
}
return response.json()
}
async function getCLIAuthSession(userCode) {
const encoded = encodeURIComponent(userCode)
return apiGet(`/api/auth/cli/sessions/${encoded}`)
}
async function approveCLIAuthSession(userCode) {
const encoded = encodeURIComponent(userCode)
return apiPost(`/api/auth/cli/sessions/${encoded}/approve`, {})
}
export const authApi = {
getOIDCConfig,
getOIDCLoginUrl,
getUserAccessOptions,
exchangeOIDCCode,
getCLIAuthSession,
approveCLIAuthSession
}
+251
View File
@@ -0,0 +1,251 @@
import { useUserStore, checkAdminPermission, checkSuperAdminPermission } from '@/stores/user'
import { message } from 'ant-design-vue'
/**
* 基础API请求封装
* 提供统一的请求方法,自动处理认证头和错误
*/
/**
* 发送API请求的基础函数
* @param {string} url - API端点
* @param {Object} options - 请求选项
* @param {boolean} requiresAuth - 是否需要认证头
* @param {string} responseType - 响应类型: 'json' | 'text' | 'blob'
* @returns {Promise} - 请求结果
*/
export async function apiRequest(url, options = {}, requiresAuth = true, responseType = 'json') {
try {
const isFormData = options?.body instanceof FormData
// 默认请求配置
const requestOptions = {
...options,
headers: {
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
...options.headers
}
}
// 如果需要认证,添加认证头
if (requiresAuth) {
const userStore = useUserStore()
if (!userStore.isLoggedIn) {
throw new Error('用户未登录')
}
Object.assign(requestOptions.headers, userStore.getAuthHeaders())
}
// 发送请求
const response = await fetch(url, requestOptions)
// 处理API返回的错误
if (!response.ok) {
// 尝试解析错误信息
let errorMessage = `请求失败: ${response.status}, ${response.statusText}`
let errorData = null
console.log('API请求失败:', {
url,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
})
try {
errorData = await response.json()
// detail 可能是字符串,也可能是结构化对象(如 { error, message }),后者需取出可读文案,
// 否则直接拼接会得到 "[object Object]"。
const detail = errorData.detail
if (detail && typeof detail === 'object') {
errorMessage = detail.message || detail.error || errorMessage
} else {
errorMessage = detail || errorData.message || errorMessage
}
console.log('API错误详情:', errorData)
// 如果是422错误,打印更详细的信息
if (response.status === 422) {
console.error('422验证错误详情:', {
url,
requestMethod: requestOptions.method,
requestHeaders: requestOptions.headers,
requestBody: requestOptions.body,
responseData: errorData
})
}
} catch (e) {
// 如果无法解析JSON,使用默认错误信息
console.log('无法解析错误响应JSON:', e)
}
// 特殊处理401和403错误
const error = new Error(errorMessage)
error.response = {
status: response.status,
statusText: response.statusText,
data: errorData
}
if (response.status === 401) {
// 如果是认证失败,可能需要重新登录
const userStore = useUserStore()
// 检查是否是token过期(errorMessage 已统一为字符串,避免对对象 detail 调用 includes 抛错)
const isTokenExpired =
errorMessage?.includes('令牌已过期') || errorMessage?.includes('token expired')
message.error(isTokenExpired ? '登录已过期,请重新登录' : '认证失败,请重新登录')
// 如果用户当前认为自己已登录,则登出
if (userStore.isLoggedIn) {
userStore.logout()
}
// 使用setTimeout确保消息显示后再跳转
setTimeout(() => {
window.location.href = '/login'
}, 1500)
throw error
} else if (response.status === 403) {
error.message = '没有权限执行此操作'
throw error
} else if (response.status === 500) {
error.message = '服务器内部错误,请使用 docker logs api-dev 查看详细日志'
throw error
}
throw error
}
// 根据responseType处理响应
if (responseType === 'blob') {
return response
} else if (responseType === 'json') {
// 检查Content-Type以确定如何处理响应
const contentType = response.headers.get('Content-Type')
if (contentType && contentType.includes('application/json')) {
return await response.json()
}
return await response.text()
} else if (responseType === 'text') {
return await response.text()
} else {
return response
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('API请求错误:', error)
}
throw error
}
}
/**
* 发送GET请求
* @param {string} url - API端点
* @param {Object} options - 请求选项
* @param {boolean} requiresAuth - 是否需要认证
* @param {string} responseType - 响应类型: 'json' | 'text' | 'blob'
* @returns {Promise} - 请求结果
*/
export function apiGet(url, options = {}, requiresAuth = true, responseType = 'json') {
return apiRequest(url, { method: 'GET', ...options }, requiresAuth, responseType)
}
export function apiAdminGet(url, options = {}, responseType = 'json') {
checkAdminPermission()
return apiGet(url, options, true, responseType)
}
export function apiSuperAdminGet(url, options = {}, responseType = 'json') {
checkSuperAdminPermission()
return apiGet(url, options, true, responseType)
}
/**
* 发送POST请求
* @param {string} url - API端点
* @param {Object} data - 请求体数据
* @param {Object} options - 其他请求选项
* @param {boolean} requiresAuth - 是否需要认证
* @param {string} responseType - 响应类型: 'json' | 'text' | 'blob'
* @returns {Promise} - 请求结果
*/
export function apiPost(url, data = {}, options = {}, requiresAuth = true, responseType = 'json') {
return apiRequest(
url,
{
method: 'POST',
body: data instanceof FormData ? data : JSON.stringify(data),
...options
},
requiresAuth,
responseType
)
}
export function apiAdminPost(url, data = {}, options = {}, responseType = 'json') {
checkAdminPermission()
return apiPost(url, data, options, true, responseType)
}
export function apiSuperAdminPost(url, data = {}, options = {}, responseType = 'json') {
checkSuperAdminPermission()
return apiPost(url, data, options, true, responseType)
}
/**
* 发送PUT请求
* @param {string} url - API端点
* @param {Object} data - 请求体数据
* @param {Object} options - 其他请求选项
* @param {boolean} requiresAuth - 是否需要认证
* @param {string} responseType - 响应类型: 'json' | 'text' | 'blob'
* @returns {Promise} - 请求结果
*/
export function apiPut(url, data = {}, options = {}, requiresAuth = true, responseType = 'json') {
return apiRequest(
url,
{
method: 'PUT',
body: data instanceof FormData ? data : JSON.stringify(data),
...options
},
requiresAuth,
responseType
)
}
export function apiAdminPut(url, data = {}, options = {}, responseType = 'json') {
checkAdminPermission()
return apiPut(url, data, options, true, responseType)
}
export function apiSuperAdminPut(url, data = {}, options = {}, responseType = 'json') {
checkSuperAdminPermission()
return apiPut(url, data, options, true, responseType)
}
/**
* 发送DELETE请求
* @param {string} url - API端点
* @param {Object} options - 请求选项
* @param {boolean} requiresAuth - 是否需要认证
* @param {string} responseType - 响应类型: 'json' | 'text' | 'blob'
* @returns {Promise} - 请求结果
*/
export function apiDelete(url, options = {}, requiresAuth = true, responseType = 'json') {
return apiRequest(url, { method: 'DELETE', ...options }, requiresAuth, responseType)
}
export function apiAdminDelete(url, options = {}) {
checkAdminPermission()
return apiDelete(url, options, true)
}
export function apiSuperAdminDelete(url, options = {}) {
checkSuperAdminPermission()
return apiDelete(url, options, true)
}
+132
View File
@@ -0,0 +1,132 @@
import { apiAdminGet } from './base'
/**
* Dashboard API模块
* 用于管理员查看所有用户的对话记录
*/
export const dashboardApi = {
/**
* 获取所有对话记录
* @param {Object} params - 查询参数
* @param {string} params.uid - 用户 UID 过滤
* @param {string} params.agent_id - 智能体ID过滤
* @param {string} params.status - 状态过滤 (active/deleted/all)
* @param {number} params.limit - 每页数量
* @param {number} params.offset - 偏移量
* @returns {Promise<Array>} - 对话列表
*/
getConversations: (params = {}) => {
const queryParams = new URLSearchParams()
if (params.uid) queryParams.append('uid', params.uid)
if (params.agent_id) queryParams.append('agent_id', params.agent_id)
if (params.status) queryParams.append('status', params.status)
if (params.limit) queryParams.append('limit', params.limit)
if (params.offset) queryParams.append('offset', params.offset)
return apiAdminGet(`/api/dashboard/conversations?${queryParams.toString()}`)
},
/**
* 获取对话详情
* @param {string} threadId - 对话线程ID
* @returns {Promise<Object>} - 对话详情
*/
getConversationDetail: (threadId) => {
return apiAdminGet(`/api/dashboard/conversations/${threadId}`)
},
/**
* 获取Dashboard统计信息
* @returns {Promise<Object>} - 统计信息
*/
getStats: () => {
return apiAdminGet('/api/dashboard/stats')
},
/**
* 获取用户反馈列表
* @param {Object} params - 查询参数
* @param {string} params.rating - 反馈类型过滤 (like/dislike/all)
* @param {string} params.agent_id - 智能体ID过滤
* @returns {Promise<Array>} - 反馈列表
*/
getFeedbacks: (params = {}) => {
const queryParams = new URLSearchParams()
if (params.rating && params.rating !== 'all') queryParams.append('rating', params.rating)
if (params.agent_id) queryParams.append('agent_id', params.agent_id)
return apiAdminGet(`/api/dashboard/feedbacks?${queryParams.toString()}`)
},
// ========== 新增并行API接口 ==========
/**
* 获取用户活跃度统计
* @returns {Promise<Object>} - 用户活跃度统计信息
*/
getUserStats: () => {
return apiAdminGet('/api/dashboard/stats/users')
},
/**
* 获取工具调用统计
* @returns {Promise<Object>} - 工具调用统计信息
*/
getToolStats: () => {
return apiAdminGet('/api/dashboard/stats/tools')
},
/**
* 获取知识库统计
* @returns {Promise<Object>} - 知识库统计信息
*/
getKnowledgeStats: () => {
return apiAdminGet('/api/dashboard/stats/knowledge')
},
/**
* 获取AI智能体分析数据
* @returns {Promise<Object>} - AI智能体分析信息
*/
getAgentStats: () => {
return apiAdminGet('/api/dashboard/stats/agents')
},
/**
* 批量获取所有统计数据(并行请求)
* @returns {Promise<Object>} - 所有统计数据
*/
getAllStats: async () => {
try {
const [basicStats, userStats, toolStats, knowledgeStats, agentStats] = await Promise.all([
apiAdminGet('/api/dashboard/stats'),
apiAdminGet('/api/dashboard/stats/users'),
apiAdminGet('/api/dashboard/stats/tools'),
apiAdminGet('/api/dashboard/stats/knowledge'),
apiAdminGet('/api/dashboard/stats/agents')
])
return {
basic: basicStats,
users: userStats,
tools: toolStats,
knowledge: knowledgeStats,
agents: agentStats
}
} catch (error) {
console.error('批量获取统计数据失败:', error)
throw error
}
},
/**
* 获取调用统计时间序列数据
* @param {string} type - 数据类型 (models/agents/tokens/tools)
* @param {string} timeRange - 时间范围 (14hours/14days/14weeks)
* @returns {Promise<Object>} - 时间序列统计数据
*/
getCallTimeseries: (type = 'models', timeRange = '14days') => {
return apiAdminGet(`/api/dashboard/stats/calls/timeseries?type=${type}&time_range=${timeRange}`)
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 部门管理 API
*/
import {
apiAdminGet,
apiSuperAdminGet,
apiSuperAdminPost,
apiSuperAdminPut,
apiSuperAdminDelete
} from './base'
const BASE_URL = '/api/departments'
/**
* 获取部门列表(普通管理员可访问)
* @returns {Promise<Array>} 部门列表
*/
export const getDepartments = () => {
return apiAdminGet(BASE_URL)
}
/**
* 获取部门详情
* @param {number} departmentId - 部门ID
* @returns {Promise<Object>} 部门详情
*/
export const getDepartment = (departmentId) => {
return apiSuperAdminGet(`${BASE_URL}/${departmentId}`)
}
/**
* 创建部门
* @param {Object} data - 部门数据
* @param {string} data.name - 部门名称
* @param {string} [data.description] - 部门描述
* @returns {Promise<Object>} 创建的部门
*/
export const createDepartment = (data) => {
return apiSuperAdminPost(BASE_URL, data)
}
/**
* 更新部门
* @param {number} departmentId - 部门ID
* @param {Object} data - 部门数据
* @param {string} [data.name] - 部门名称
* @param {string} [data.description] - 部门描述
* @returns {Promise<Object>} 更新后的部门
*/
export const updateDepartment = (departmentId, data) => {
return apiSuperAdminPut(`${BASE_URL}/${departmentId}`, data)
}
/**
* 删除部门
* @param {number} departmentId - 部门ID
* @returns {Promise<Object>} 删除结果
*/
export const deleteDepartment = (departmentId) => {
return apiSuperAdminDelete(`${BASE_URL}/${departmentId}`)
}
export const departmentApi = {
getDepartments,
getDepartment,
createDepartment,
updateDepartment,
deleteDepartment
}
+51
View File
@@ -0,0 +1,51 @@
import { apiGet } from './base'
export const graphApi = {
getGraphs: async () => {
return await apiGet('/api/graph/list', {}, true)
},
getSubgraph: async (params) => {
const {
kb_id,
node_label = '*',
max_depth = 2,
max_nodes = 100,
exclude_chunk = false
} = params
if (!kb_id) {
throw new Error('kb_id is required')
}
const queryParams = new URLSearchParams({
kb_id,
node_label,
max_depth: max_depth.toString(),
max_nodes: max_nodes.toString(),
exclude_chunk: exclude_chunk.toString()
})
return await apiGet(`/api/graph/subgraph?${queryParams.toString()}`, {}, true)
},
getStats: async (kb_id) => {
if (!kb_id) {
throw new Error('kb_id is required')
}
const queryParams = new URLSearchParams({ kb_id })
return await apiGet(`/api/graph/stats?${queryParams.toString()}`, {}, true)
},
getLabels: async (kb_id) => {
if (!kb_id) {
throw new Error('kb_id is required')
}
const queryParams = new URLSearchParams({ kb_id })
return await apiGet(`/api/graph/labels?${queryParams.toString()}`, {}, true)
}
}
export const unifiedApi = graphApi
+58
View File
@@ -0,0 +1,58 @@
/**
* API模块索引文件
* 导出所有API模块,方便统一引入
*/
// 导出API模块
export * from './system_api' // 系统管理API
export * from './knowledge_api' // 知识库管理API
export * from './graph_api' // 图谱API
export * from './agent_api' // 智能体API
export * from './tasker' // 任务管理API
export * from './department_api' // 部门管理API
export * from './mcp_api' // MCP API
export * from './skill_api' // Skills API
export * from './tool_api' // 工具 API
export * from './mention_api' // 提及搜索 API
export * from './user_api' // 用户资源 API
export * from './user_config_api' // 用户配置 API
// 导出基础工具函数
export {
apiGet,
apiPost,
apiPut,
apiDelete,
apiAdminGet,
apiAdminPost,
apiAdminPut,
apiAdminDelete,
apiSuperAdminGet,
apiSuperAdminPost,
apiSuperAdminPut,
apiSuperAdminDelete
} from './base'
/**
* API模块说明:
*
* 1. system_api.js: 系统管理API
* - 健康检查、配置管理、信息管理、OCR服务
* - 权限要求: 部分公开,部分需要管理员权限
*
* 2. knowledge_api.js: 知识库管理API
* - 数据库管理、文档管理、查询接口、文件管理
* - 权限要求: 管理员权限
*
*
* 4. graph_api.js: 图谱API
* - 知识图谱相关功能
*
* 5. tools.js: 工具API
* - 工具信息获取
*
* 6. agent.js: 智能体API
* - 智能体管理、聊天、配置等功能
*
* 注意:API模块已处理权限验证和请求头,使用时无需再手动添加认证头
*/
+607
View File
@@ -0,0 +1,607 @@
import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete, apiRequest } from './base'
/**
* 知识库管理API模块
* 包含数据库管理、文档管理、查询接口等功能
*/
// =============================================================================
// === 数据库管理分组 ===
// =============================================================================
export const databaseApi = {
/**
* 获取所有知识库
* @returns {Promise} - 知识库列表
*/
getDatabases: async () => {
return apiAdminGet('/api/knowledge/databases')
},
/**
* 创建知识库
* @param {Object} databaseData - 知识库数据
* @returns {Promise} - 创建结果
*/
createDatabase: async (databaseData) => {
return apiAdminPost('/api/knowledge/databases', databaseData)
},
/**
* 获取知识库详细信息
* @param {string} kbId - 知识库ID
* @returns {Promise} - 知识库信息
*/
getDatabaseInfo: async (kbId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}`)
},
/**
* 修复知识库文件统计
* @param {string} kbId - 知识库ID
* @returns {Promise} - 修复结果
*/
repairDatabaseStats: async (kbId) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/stats/repair`, {})
},
/**
* 更新知识库信息
* @param {string} kbId - 知识库ID
* @param {Object} updateData - 更新数据
* @returns {Promise} - 更新结果
*/
updateDatabase: async (kbId, updateData) => {
return apiAdminPut(`/api/knowledge/databases/${kbId}`, updateData)
},
/**
* 删除知识库
* @param {string} kbId - 知识库ID
* @returns {Promise} - 删除结果
*/
deleteDatabase: async (kbId) => {
return apiAdminDelete(`/api/knowledge/databases/${kbId}`)
},
/**
* 使用 AI 生成或优化知识库描述
* @param {string} name - 知识库名称
* @param {string} currentDescription - 当前描述(可选)
* @param {Array} fileList - 文件列表(可选)
* @returns {Promise} - 生成结果
*/
generateDescription: async (name, currentDescription = '', fileList = []) => {
return apiAdminPost('/api/knowledge/generate-description', {
name,
current_description: currentDescription,
file_list: fileList
})
},
/**
* 获取当前用户有权访问的知识库列表(用于智能体配置)
* @returns {Promise} - 可访问的知识库列表
*/
getAccessibleDatabases: async () => {
return apiGet('/api/knowledge/databases/accessible')
}
}
// =============================================================================
// === 文档管理分组 ===
// =============================================================================
const buildQuery = (params) => {
const query = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
})
return query.toString()
}
export const documentApi = {
/**
* 分页获取知识库文档列表
* @param {string} kbId - 知识库ID
* @param {Object} params - 查询参数
* @returns {Promise} - 文档列表
*/
listDocuments: async (kbId, params = {}) => {
const query = buildQuery(params)
return apiAdminGet(`/api/knowledge/databases/${kbId}/documents${query ? `?${query}` : ''}`)
},
/**
* 检查知识库中是否存在指定文件名或相对路径
* @param {string} kbId - 知识库ID
* @param {string} filename - 文件名或相对路径
* @returns {Promise} - 存在性检查结果
*/
documentExists: async (kbId, filename) => {
const query = buildQuery({ filename })
return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/exists?${query}`)
},
/**
* 创建文件夹
* @param {string} kbId - 知识库ID
* @param {string} folderName - 文件夹名称
* @param {string} parentId - 父文件夹ID
* @returns {Promise} - 创建结果
*/
createFolder: async (kbId, folderName, parentId = null) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/folders`, {
folder_name: folderName,
parent_id: parentId
})
},
/**
* 添加文档到知识库
* @param {string} kbId - 知识库ID
* @param {Array} items - 文档列表
* @param {Object} params - 处理参数
* @returns {Promise} - 添加结果
*/
addDocuments: async (kbId, items, params = {}) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/documents`, {
items,
params
})
},
/**
* 将已上传文件添加为知识库文档记录(不解析、不入库)
* @param {string} kbId - 知识库ID
* @param {Array} items - 已上传文件的 MinIO URL 列表
* @param {Object} params - 添加参数
* @returns {Promise} - 添加结果
*/
addUploadedDocuments: async (kbId, items, params = {}) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/add`, {
items,
params
})
},
/**
* 获取文档信息
* @param {string} kbId - 知识库ID
* @param {string} docId - 文档ID
* @returns {Promise} - 文档信息
*/
getDocumentInfo: async (kbId, docId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}`)
},
/**
* 获取文档基本信息
* @param {string} kbId - 知识库ID
* @param {string} docId - 文档ID
* @returns {Promise} - 文档基本信息
*/
getDocumentBasicInfo: async (kbId, docId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/basic`)
},
/**
* 获取文档解析内容和分块
* @param {string} kbId - 知识库ID
* @param {string} docId - 文档ID
* @returns {Promise} - 文档内容信息
*/
getDocumentContent: async (kbId, docId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/content`)
},
/**
* 删除文档
* @param {string} kbId - 知识库ID
* @param {string} docId - 文档ID
* @returns {Promise} - 删除结果
*/
deleteDocument: async (kbId, docId) => {
return apiAdminDelete(`/api/knowledge/databases/${kbId}/documents/${docId}`)
},
/**
* 批量删除文档
* @param {string} kbId - 知识库ID
* @param {Array} fileIds - 文件ID列表
* @returns {Promise} - 批量删除结果
*/
batchDeleteDocuments: async (kbId, fileIds) => {
return apiRequest(
`/api/knowledge/databases/${kbId}/documents/batch`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(fileIds)
},
true,
'json'
)
},
/**
* 下载文档
* @param {string} kbId - 知识库ID
* @param {string} docId - 文档ID
* @returns {Promise} - Response对象
*/
downloadDocument: async (kbId, docId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/download`, {}, 'blob')
},
/**
* 手动触发文档解析
* @param {string} kbId - 知识库ID
* @param {Array} fileIds - 文件ID列表
* @returns {Promise} - 解析任务结果
*/
parseDocuments: async (kbId, fileIds) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/parse`, fileIds)
},
/**
* 手动触发全部待解析文档解析
* @param {string} kbId - 知识库ID
* @returns {Promise} - 解析任务结果
*/
parsePendingDocuments: async (kbId) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/parse-pending`, {})
},
/**
* 手动触发文档入库
* @param {string} kbId - 知识库ID
* @param {Array} fileIds - 文件ID列表
* @param {Object} params - 处理参数
* @returns {Promise} - 入库任务结果
*/
indexDocuments: async (kbId, fileIds, params = {}) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/index`, {
file_ids: fileIds,
params
})
},
/**
* 手动触发全部待入库文档入库
* @param {string} kbId - 知识库ID
* @param {Object} params - 处理参数
* @returns {Promise} - 入库任务结果
*/
indexPendingDocuments: async (kbId, params = {}) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/index-pending`, {
params
})
}
}
// =============================================================================
// === 图谱构建分组 ===
// =============================================================================
function graphBuildUrl(kbId, action) {
return `/api/knowledge/databases/${kbId}/graph-build/${action}`
}
export const graphBuildApi = {
getStatus: async (kbId) => {
return apiAdminGet(graphBuildUrl(kbId, 'status'))
},
configure: async (kbId, data) => {
return apiAdminPost(graphBuildUrl(kbId, 'config'), data)
},
startIndex: async (kbId, batchSize = 20) => {
return apiAdminPost(graphBuildUrl(kbId, 'index'), {
batch_size: batchSize
})
},
reset: async (kbId, data) => {
return apiAdminPost(graphBuildUrl(kbId, 'reset'), data)
}
}
// =============================================================================
// === 思维导图分组 ===
// =============================================================================
export const mindmapApi = {
getDatabases: async () => {
return apiAdminGet('/api/knowledge/mindmap/databases')
},
getDatabaseFiles: async (kbId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap/files`)
},
generateMindmap: async (kbId, fileIds = [], userPrompt = '', incremental = false) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/mindmap/generate`, {
file_ids: fileIds,
user_prompt: userPrompt,
incremental
})
},
getByDatabase: async (kbId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap`)
},
getDiff: async (kbId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap/diff`)
}
}
// =============================================================================
// === 查询分组 ===
// =============================================================================
export const queryApi = {
/**
* 查询知识库
* @param {string} kbId - 知识库ID
* @param {string} query - 查询文本
* @param {Object} meta - 查询参数
* @returns {Promise} - 查询结果
*/
queryKnowledgeBase: async (kbId, query, meta = {}) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/query`, {
query,
meta
})
},
/**
* 测试查询知识库
* @param {string} kbId - 知识库ID
* @param {string} query - 查询文本
* @param {Object} meta - 查询参数
* @returns {Promise} - 测试结果
*/
queryTest: async (kbId, query, meta = {}) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/query-test`, {
query,
meta
})
},
/**
* 获取知识库查询参数
* @param {string} kbId - 知识库ID
* @returns {Promise} - 查询参数
*/
getKnowledgeBaseQueryParams: async (kbId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/query-params`)
},
/**
* 更新知识库查询参数
* @param {string} kbId - 知识库ID
* @param {Object} params - 查询参数
* @returns {Promise} - 更新结果
*/
updateKnowledgeBaseQueryParams: async (kbId, params) => {
return apiAdminPut(`/api/knowledge/databases/${kbId}/query-params`, params)
},
/**
* 生成知识库的测试问题
* @param {string} kbId - 知识库ID
* @param {number} count - 生成问题数量,默认10
* @returns {Promise} - 生成的问题列表
*/
generateSampleQuestions: async (kbId, count = 10) => {
return apiAdminPost(`/api/knowledge/databases/${kbId}/sample-questions`, {
count
})
},
/**
* 获取知识库的测试问题
* @param {string} kbId - 知识库ID
* @returns {Promise} - 问题列表
*/
getSampleQuestions: async (kbId) => {
return apiAdminGet(`/api/knowledge/databases/${kbId}/sample-questions`)
}
}
// =============================================================================
// === 文件管理分组 ===
// =============================================================================
export const fileApi = {
/**
* 抓取 URL 内容
* @param {string} url - 目标 URL
* @param {string} kbId - 知识库 ID
* @returns {Promise} - 抓取结果
*/
fetchUrl: async (url, kbId = null) => {
return apiAdminPost('/api/knowledge/files/fetch-url', {
url,
kb_id: kbId
})
},
/**
* 从工作区导入文件到知识库 MinIO 暂存区
* @param {string} kbId - 知识库 ID
* @param {Array<string>} paths - 工作区文件路径
* @returns {Promise} - 导入结果
*/
importWorkspaceFiles: async (kbId, paths) => {
return apiAdminPost('/api/knowledge/files/import-workspace', {
kb_id: kbId,
paths
})
},
/**
* 上传文件
* @param {File} file - 文件对象
* @param {string} kbId - 知识库ID(可选)
* @returns {Promise} - 上传结果
*/
uploadFile: async (file, kbId = null) => {
const formData = new FormData()
formData.append('file', file)
const url = kbId ? `/api/knowledge/files/upload?kb_id=${kbId}` : '/api/knowledge/files/upload'
return apiAdminPost(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
/**
* 获取支持的文件类型
* @returns {Promise} - 文件类型列表
*/
getSupportedFileTypes: async () => {
return apiAdminGet('/api/knowledge/files/supported-types')
},
/**
* 上传文件夹(zip格式)
* @param {File} file - zip文件
* @param {string} kbId - 知识库ID
* @returns {Promise} - 上传结果
*/
uploadFolder: async (file, kbId) => {
const formData = new FormData()
formData.append('file', file)
// 使用 apiRequest 直接发送 FormData,但使用统一的错误处理
return apiRequest(
`/api/knowledge/files/upload-folder?kb_id=${kbId}`,
{
method: 'POST',
body: formData
// 不设置 Content-Type,让浏览器自动设置 boundary
},
true,
'json'
) // 需要认证,期望JSON响应
},
/**
* 处理文件夹(异步处理zip文件)
* @param {Object} data - 处理参数
* @param {string} data.file_path - 已上传的zip文件路径
* @param {string} data.kb_id - 知识库ID
* @param {string} data.content_hash - 文件内容哈希
* @returns {Promise} - 处理任务结果
*/
processFolder: async ({ file_path, kb_id, content_hash }) => {
return apiAdminPost('/api/knowledge/files/process-folder', {
file_path,
kb_id,
content_hash
})
}
}
// =============================================================================
// === 知识库类型分组 ===
// =============================================================================
export const typeApi = {
/**
* 获取支持的知识库类型
* @returns {Promise} - 知识库类型列表
*/
getKnowledgeBaseTypes: async () => {
return apiAdminGet('/api/knowledge/types')
},
/**
* 获取支持的知识库分块策略
* @returns {Promise} - 分块策略列表
*/
getChunkPresets: async () => {
return apiAdminGet('/api/knowledge/chunk-presets')
},
/**
* 获取知识库统计信息
* @returns {Promise} - 统计信息
*/
getStatistics: async () => {
return apiAdminGet('/api/knowledge/stats')
}
}
// =============================================================================
// === RAG评估分组 ===
// =============================================================================
export const evaluationApi = {
uploadDataset: async (kbId, file, metadata = {}) => {
const formData = new FormData()
formData.append('file', file)
formData.append('name', metadata.name || '')
formData.append('description', metadata.description || '')
return apiAdminPost(`/api/evaluation/databases/${kbId}/datasets/upload`, formData)
},
listDatasets: async (kbId) => {
return apiAdminGet(`/api/evaluation/databases/${kbId}/datasets`)
},
getDataset: async (kbId, datasetId, page = 1, pageSize = 50) => {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString()
})
return apiAdminGet(`/api/evaluation/databases/${kbId}/datasets/${datasetId}?${params}`)
},
deleteDataset: async (datasetId) => {
return apiAdminDelete(`/api/evaluation/datasets/${datasetId}`)
},
downloadDataset: async (datasetId) => {
return apiAdminGet(`/api/evaluation/datasets/${datasetId}/download`, {}, 'blob')
},
generateDataset: async (kbId, params) => {
return apiAdminPost(`/api/evaluation/databases/${kbId}/datasets/generate`, params)
},
runEvaluation: async (kbId, params) => {
return apiAdminPost(`/api/evaluation/databases/${kbId}/runs`, params)
},
listRuns: async (kbId) => {
return apiAdminGet(`/api/evaluation/databases/${kbId}/runs`)
},
getRunResults: async (kbId, runId, params = {}) => {
const queryParams = new URLSearchParams()
if (params.page) queryParams.append('page', params.page)
if (params.pageSize) queryParams.append('page_size', params.pageSize)
if (params.errorOnly !== undefined) queryParams.append('error_only', params.errorOnly)
const url = `/api/evaluation/databases/${kbId}/runs/${runId}${queryParams.toString() ? '?' + queryParams.toString() : ''}`
return apiAdminGet(url)
},
deleteRun: async (kbId, runId) => {
return apiAdminDelete(`/api/evaluation/databases/${kbId}/runs/${runId}`)
}
}
+130
View File
@@ -0,0 +1,130 @@
import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
/**
* MCP 服务器管理 API 模块
* 包含 MCP 服务器的增删改查和工具管理功能
*/
const BASE_URL = '/api/system/mcp-servers'
// =============================================================================
// === MCP 服务器 CRUD ===
// =============================================================================
/**
* 获取所有 MCP 服务器配置
* @returns {Promise} - 服务器列表
*/
export const getMcpServers = async () => {
return apiGet(BASE_URL)
}
/**
* 获取单个 MCP 服务器配置
* @param {string} name - 服务器名称
* @returns {Promise} - 服务器配置
*/
export const getMcpServer = async (name) => {
return apiAdminGet(`${BASE_URL}/${encodeURIComponent(name)}`)
}
/**
* 创建新的 MCP 服务器
* @param {Object} data - 服务器配置数据
* @returns {Promise} - 创建结果
*/
export const createMcpServer = async (data) => {
return apiAdminPost(BASE_URL, data)
}
/**
* 更新 MCP 服务器配置
* @param {string} name - 服务器名称
* @param {Object} data - 更新数据
* @returns {Promise} - 更新结果
*/
export const updateMcpServer = async (name, data) => {
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(name)}`, data)
}
/**
* 删除 MCP 服务器
* @param {string} name - 服务器名称
* @returns {Promise} - 删除结果
*/
export const deleteMcpServer = async (name) => {
return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(name)}`)
}
// =============================================================================
// === MCP 服务器操作 ===
// =============================================================================
/**
* 测试 MCP 服务器连接
* @param {string} name - 服务器名称
* @returns {Promise} - 测试结果
*/
export const testMcpServer = async (name) => {
return apiAdminPost(`${BASE_URL}/${encodeURIComponent(name)}/test`, {})
}
/**
* 更新 MCP 服务器启用状态
* @param {string} name - 服务器名称
* @param {boolean} enabled - 是否启用
* @returns {Promise} - 切换结果
*/
export const updateMcpServerStatus = async (name, enabled) => {
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(name)}/status`, { enabled })
}
// =============================================================================
// === MCP 工具管理 ===
// =============================================================================
/**
* 获取 MCP 服务器的工具列表
* @param {string} name - 服务器名称
* @returns {Promise} - 工具列表
*/
export const getMcpServerTools = async (name) => {
return apiAdminGet(`${BASE_URL}/${encodeURIComponent(name)}/tools`)
}
/**
* 刷新 MCP 服务器的工具列表(清除缓存重新获取)
* @param {string} name - 服务器名称
* @returns {Promise} - 刷新结果
*/
export const refreshMcpServerTools = async (name) => {
return apiAdminPost(`${BASE_URL}/${encodeURIComponent(name)}/tools/refresh`, {})
}
/**
* 切换单个工具的启用状态
* @param {string} serverName - 服务器名称
* @param {string} toolName - 工具名称
* @returns {Promise} - 切换结果
*/
export const toggleMcpServerTool = async (serverName, toolName) => {
return apiAdminPut(
`${BASE_URL}/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
{}
)
}
export const mcpApi = {
getMcpServers,
getMcpServer,
createMcpServer,
updateMcpServer,
deleteMcpServer,
testMcpServer,
updateMcpServerStatus,
getMcpServerTools,
refreshMcpServerTools,
toggleMcpServerTool
}
export default mcpApi
+8
View File
@@ -0,0 +1,8 @@
import { apiGet } from './base'
export const searchMentionFiles = (threadId, query, signal) => {
const params = new URLSearchParams()
if (threadId) params.set('thread_id', threadId)
if (query) params.set('query', query)
return apiGet(`/api/mention/search?${params.toString()}`, { signal })
}
+126
View File
@@ -0,0 +1,126 @@
import { apiGet, apiPost, apiPut, apiDelete, apiAdminGet, apiAdminPost } from './base'
const BASE_URL = '/api/system/skills'
const USER_BASE_URL = '/api/skills'
export const listSkills = async () => {
return apiGet(BASE_URL)
}
export const listAccessibleSkills = async () => {
return apiGet(`${USER_BASE_URL}/accessible`)
}
export const prepareSkillUpload = async (file) => {
const formData = new FormData()
formData.append('file', file)
return apiPost(`${USER_BASE_URL}/import/prepare`, formData)
}
export const listRemoteSkills = async (source) => {
return apiPost(`${USER_BASE_URL}/remote/list`, { source })
}
export const prepareRemoteSkills = async (payload) => {
return apiPost(`${USER_BASE_URL}/remote/prepare`, payload)
}
export const searchRemoteSkills = async (query) => {
return apiPost(`${USER_BASE_URL}/remote/search`, { query })
}
export const confirmSkillInstallDraft = async (draftId, shareConfig) => {
return apiPost(`${USER_BASE_URL}/install-drafts/${encodeURIComponent(draftId)}/confirm`, {
share_config: shareConfig
})
}
export const discardSkillInstallDraft = async (draftId) => {
return apiDelete(`${USER_BASE_URL}/install-drafts/${encodeURIComponent(draftId)}`)
}
export const getSkillDependencyOptions = async (slug) => {
const query = slug ? `?slug=${encodeURIComponent(slug)}` : ''
return apiGet(`${BASE_URL}/dependency-options${query}`)
}
export const listBuiltinSkills = async () => {
return apiAdminGet(`${BASE_URL}/builtin`)
}
export const syncBuiltinSkills = async () => {
return apiAdminPost(`${BASE_URL}/builtin/sync`)
}
export const getSkillTree = async (slug) => {
return apiGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`)
}
export const getSkillFile = async (slug, path) => {
return apiGet(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`)
}
export const createSkillFile = async (slug, payload) => {
return apiPost(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
}
export const updateSkillFile = async (slug, payload) => {
return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
}
export const updateSkillDependencies = async (slug, payload) => {
return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload)
}
export const updateSkillShareConfig = async (slug, shareConfig) => {
return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/share-config`, {
share_config: shareConfig
})
}
export const updateSkillEnabled = async (slug, enabled) => {
return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/enabled`, { enabled })
}
export const deleteSkillFile = async (slug, path) => {
return apiDelete(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`)
}
export const exportSkill = async (slug) => {
return apiGet(`${BASE_URL}/${encodeURIComponent(slug)}/export`, {}, true, 'blob')
}
export const deleteSkill = async (slug) => {
return apiDelete(`${BASE_URL}/${encodeURIComponent(slug)}`)
}
export const deleteSkillsBatch = async (slugs) => {
return apiPost(`${BASE_URL}/delete-batch`, { slugs })
}
export const skillApi = {
listSkills,
listAccessibleSkills,
prepareSkillUpload,
listRemoteSkills,
prepareRemoteSkills,
searchRemoteSkills,
confirmSkillInstallDraft,
discardSkillInstallDraft,
getSkillDependencyOptions,
listBuiltinSkills,
syncBuiltinSkills,
getSkillTree,
getSkillFile,
createSkillFile,
updateSkillFile,
updateSkillDependencies,
updateSkillShareConfig,
updateSkillEnabled,
deleteSkillFile,
exportSkill,
deleteSkill,
deleteSkillsBatch
}
export default skillApi
+127
View File
@@ -0,0 +1,127 @@
import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
/**
* 系统管理API模块
* 包含系统配置、健康检查、信息管理等功能
*/
// =============================================================================
// === 健康检查分组 ===
// =============================================================================
export const healthApi = {
/**
* 系统健康检查(公开接口)
* @returns {Promise} - 健康检查结果
*/
checkHealth: () => apiGet('/api/system/health', {}, false)
}
// =============================================================================
// === 配置管理分组 ===
// =============================================================================
export const configApi = {
/**
* 获取系统配置
* @returns {Promise} - 系统配置
*/
getConfig: async () => apiGet('/api/system/config'),
/**
* 更新单个配置项
* @param {string} key - 配置键
* @param {any} value - 配置值
* @returns {Promise} - 更新结果
*/
updateConfig: async (key, value) => apiAdminPost('/api/system/config', { key, value }),
/**
* 批量更新配置项
* @param {Object} items - 配置项对象
* @returns {Promise} - 更新结果
*/
updateConfigBatch: async (items) => apiAdminPost('/api/system/config/update', items),
/**
* 获取系统日志
* @param {string} levels - 可选的日志级别过滤,多个级别用逗号分隔
* @returns {Promise} - 系统日志
*/
getLogs: async (levels) => {
const url = levels
? `/api/system/logs?levels=${encodeURIComponent(levels)}`
: '/api/system/logs'
return apiAdminGet(url)
}
}
// =============================================================================
// === 信息管理分组 ===
// =============================================================================
export const brandApi = {
/**
* 获取系统信息配置(公开接口)
* @returns {Promise} - 系统信息配置
*/
getInfoConfig: () => apiGet('/api/system/info', {}, false)
}
// =============================================================================
// === OCR服务分组 ===
// =============================================================================
export const ocrApi = {
/**
* 获取OCR服务健康状态
* @returns {Promise} - OCR健康状态
*/
getHealth: async () => apiAdminGet('/api/system/ocr/health')
}
// =============================================================================
// === 聊天模型状态检查分组 ===
// =============================================================================
export const chatModelApi = {}
// =============================================================================
// === 独立模型供应商配置分组 ===
// =============================================================================
export const modelProviderApi = {
getProviders: async () => {
return apiAdminGet('/api/system/model-providers')
},
getV2Models: async (modelType = 'chat') => {
return apiGet(`/api/system/model-providers/models/v2?model_type=${modelType}`)
},
refreshModelCache: async () => {
return apiAdminPost('/api/system/model-providers/models/cache/refresh')
},
getModelStatusBySpec: async (spec) => {
return apiAdminGet(`/api/system/model-providers/models/status?spec=${encodeURIComponent(spec)}`)
},
createProvider: async (payload) => {
return apiAdminPost('/api/system/model-providers', payload)
},
updateProvider: async (providerId, payload) => {
return apiAdminPut(`/api/system/model-providers/${encodeURIComponent(providerId)}`, payload)
},
deleteProvider: async (providerId) => {
return apiAdminDelete(`/api/system/model-providers/${encodeURIComponent(providerId)}`)
},
fetchRemoteModels: async (providerId) => {
return apiAdminGet(
`/api/system/model-providers/${encodeURIComponent(providerId)}/remote-models`
)
}
}
+23
View File
@@ -0,0 +1,23 @@
import { apiAdminDelete, apiAdminGet, apiAdminPost } from './base'
const BASE_URL = '/api/tasks'
export const taskerApi = {
fetchTasks: async (params = {}) => {
const query = new URLSearchParams(params).toString()
const url = query ? `${BASE_URL}?${query}` : BASE_URL
return apiAdminGet(url)
},
fetchTaskDetail: async (taskId) => {
return apiAdminGet(`${BASE_URL}/${taskId}`)
},
cancelTask: async (taskId) => {
return apiAdminPost(`${BASE_URL}/${taskId}/cancel`, {})
},
deleteTask: async (taskId) => {
return apiAdminDelete(`${BASE_URL}/${taskId}`)
}
}
+33
View File
@@ -0,0 +1,33 @@
import { apiAdminGet } from './base'
/**
* 工具管理 API 模块
* 包含系统内置工具的查询功能
*/
const BASE_URL = '/api/system/tools'
/**
* 获取工具列表
* @param {string} category - 可选,按分类筛选
* @returns {Promise} - 工具列表
*/
export const getTools = async (category = null) => {
const query = category ? `?${new URLSearchParams({ category }).toString()}` : ''
return apiAdminGet(`${BASE_URL}${query}`)
}
/**
* 获取工具选项列表(用于下拉选择)
* @returns {Promise} - 工具选项
*/
export const getToolOptions = async () => {
return apiAdminGet(`${BASE_URL}/options`)
}
export const toolApi = {
getTools,
getToolOptions
}
export default toolApi
+9
View File
@@ -0,0 +1,9 @@
import { apiPost } from './base'
export const userApi = {
uploadImage: (file) => {
const formData = new FormData()
formData.append('file', file)
return apiPost('/api/user/upload-image', formData)
}
}
+9
View File
@@ -0,0 +1,9 @@
import { apiGet, apiPut } from './base'
const USER_CONFIG_PATH = '/api/user/config'
export const userConfigApi = {
get: () => apiGet(USER_CONFIG_PATH),
update: (config) => apiPut(USER_CONFIG_PATH, config)
}
+54
View File
@@ -0,0 +1,54 @@
import { apiDelete, apiGet, apiPost } from './base'
const buildQuery = (params) => {
const query = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
})
return query.toString()
}
const buildViewerQuery = (threadId, path) => {
return buildQuery({
thread_id: threadId,
path
})
}
export const getViewerFileSystemTree = (threadId, path = '/') => {
const query = buildViewerQuery(threadId, path)
return apiGet(`/api/viewer/filesystem/tree?${query}`)
}
export const getViewerFileContent = (threadId, path) => {
const query = buildViewerQuery(threadId, path)
return apiGet(`/api/viewer/filesystem/file?${query}`, {}, true, 'blob')
}
export const downloadViewerFile = (threadId, path) => {
const query = buildViewerQuery(threadId, path)
return apiGet(`/api/viewer/filesystem/download?${query}`, {}, true, 'blob')
}
export const deleteViewerFile = (threadId, path) => {
const query = buildViewerQuery(threadId, path)
return apiDelete(`/api/viewer/filesystem/file?${query}`)
}
export const createViewerDirectory = (threadId, parentPath, name) => {
return apiPost('/api/viewer/filesystem/directory', {
thread_id: threadId,
parent_path: parentPath,
name
})
}
export const uploadViewerFiles = (threadId, parentPath, files) => {
const formData = new FormData()
formData.set('thread_id', threadId)
formData.set('parent_path', parentPath)
files.forEach((file) => formData.append('files', file))
return apiPost('/api/viewer/filesystem/upload', formData)
}
+72
View File
@@ -0,0 +1,72 @@
import { apiDelete, apiGet, apiPost, apiPut } from './base'
const buildQuery = (params) => {
const query = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
})
return query.toString()
}
export const getWorkspaceTree = (path = '/', recursive = false, filesOnly = false) => {
const query = buildQuery({ path, recursive, files_only: filesOnly })
return apiGet(`/api/workspace/tree?${query}`)
}
export const getWorkspaceFileContent = (path) => {
const query = buildQuery({ path })
return apiGet(`/api/workspace/file?${query}`, {}, true, 'blob')
}
export const getWorkspaceKnowledgeTree = (kbId, params = {}) => {
const query = buildQuery({
kb_id: kbId,
parent_id: params.parentId,
path_prefix: params.pathPrefix,
page: params.page,
page_size: params.pageSize,
recursive: params.recursive || false,
files_only: params.filesOnly || false
})
return apiGet(`/api/workspace/knowledge/tree?${query}`)
}
export const getWorkspaceKnowledgeFileContent = (kbId, fileId) => {
const query = buildQuery({ kb_id: kbId, file_id: fileId })
return apiGet(`/api/workspace/knowledge/file?${query}`, {}, true, 'blob')
}
export const downloadWorkspaceKnowledgeFile = (kbId, fileId, variant = 'original') => {
const query = buildQuery({ kb_id: kbId, file_id: fileId, variant })
return apiGet(`/api/workspace/knowledge/download?${query}`, {}, true, 'blob')
}
export const saveWorkspaceFileContent = (path, content) => {
return apiPut('/api/workspace/file', { path, content })
}
export const deleteWorkspacePath = (path) => {
const query = buildQuery({ path })
return apiDelete(`/api/workspace/file?${query}`)
}
export const createWorkspaceDirectory = (parentPath, name) => {
return apiPost('/api/workspace/directory', {
parent_path: parentPath,
name
})
}
export const uploadWorkspaceFiles = (parentPath, files) => {
const formData = new FormData()
formData.append('parent_path', parentPath)
files.forEach((file) => formData.append('files', file))
return apiPost('/api/workspace/upload', formData)
}
export const downloadWorkspaceFile = (path) => {
const query = buildQuery({ path })
return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob')
}
+87
View File
@@ -0,0 +1,87 @@
/* 动画样式集合 */
@keyframes dotPulse {
0%,
80%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1.1);
opacity: 1;
}
}
@keyframes shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
@keyframes swing-in-top-fwd {
0% {
transform: rotateX(-100deg);
transform-origin: top;
opacity: 0;
}
100% {
transform: rotateX(0deg);
transform-origin: top;
opacity: 1;
}
}
@keyframes slideInUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
+206
View File
@@ -0,0 +1,206 @@
/* color palette from <https://github.com/vuejs/theme> */
/* https://material-foundation.github.io/material-theme-builder/ */
:root {
--main-1000: #01151f;
--main-900: #023944;
--main-800: #035065;
--main-700: #046a82;
--main-600: #24839a;
--main-500: #3996ae;
--main-400: #5faec2;
--main-300: #82c3d6;
--main-200: #a3d8e8;
--main-100: #c4eaf5;
--main-50: #e1f6fb;
--main-40: #eaf3f5;
--main-30: #f7fbfd;
--main-20: #f6f9fa;
--main-10: #fafcfd;
--main-5: #fcfefe;
--main-1: #fefefe;
--main-0: #ffffff;
/* 保留原有的 main 变量 */
--main-color: var(--main-700);
--main-bright: #0188a6;
--gray-10000: #000000; /* 保持纯黑 */
--gray-2000: #0c0d0d; /* 更精致的暗灰,用于极深背景 */
--gray-1000: #151616; /* 深灰黑,适用于主文本 */
--gray-900: #1e1f1f;
--gray-800: #323333; /* 标准深灰,适合背景 */
--gray-700: #4c4d4d;
--gray-600: #697070; /* 调和中灰,适合次要文本 */
--gray-500: #979999; /* 更柔和的中灰色 */
--gray-400: #bdbfbf;
--gray-300: #d7d9d9; /* 修正了原300/200重复 */
--gray-200: #e4e6e6; /* 稍微加深一点,便于分层 */
--gray-150: #eef0f0;
--gray-100: #eff2f2;
--gray-50: #f5f7f7;
--gray-25: #f8fafa;
--gray-10: #fbfcfc;
--gray-0: #ffffff;
/* Standard Color System - 标准5档色阶系统 */
--color-primary-50: var(--main-50); /* 主色调-最浅 */
--color-primary-100: var(--main-200); /* 主色调-浅 */
--color-primary-500: var(--main-500); /* 主色调-标准 */
--color-primary-700: var(--main-700); /* 主色调-深 */
--color-primary-900: var(--main-900); /* 主色调-最深 */
--color-secondary-10: var(--gray-10); /* 次要色-最浅 */
--color-secondary-50: var(--gray-50); /* 次要色-最浅 */
--color-secondary-100: var(--gray-100); /* 次要色-浅 */
--color-secondary-500: var(--gray-700); /* 次要色-标准 */
--color-secondary-700: var(--gray-800); /* 次要色-深 */
--color-secondary-900: var(--gray-900); /* 次要色-最深 */
--color-success-10: #fafff2; /* 成功色-极浅 */
--color-success-50: #e6f7e6; /* 成功色-最浅 */
--color-success-100: #b7eb8f; /* 成功色-浅 */
--color-success-500: #52c41a; /* 成功色-标准 */
--color-success-700: #389e0d; /* 成功色-深 */
--color-success-900: #135200; /* 成功色-最深 */
--color-error-10: #fffaf9; /* 错误色-极浅 */
--color-error-50: #fff2f0; /* 错误色-最浅 */
--color-error-100: #ffccc7; /* 错误色-浅 */
--color-error-500: #ff4d4f; /* 错误色-标准 */
--color-error-700: #cf1322; /* 错误色-深 */
--color-error-900: #820014; /* 错误色-最深 */
--color-warning-10: #fffef5; /* 警告色-极浅 */
--color-warning-50: #fffbe6; /* 警告色-最浅 */
--color-warning-100: #ffe58f; /* 警告色-浅 */
--color-warning-500: #faad14; /* 警告色-标准 */
--color-warning-700: #d48806; /* 警告色-深 */
--color-warning-900: #ad6800; /* 警告色-最深 */
--color-info-10: #f5fbff; /* 信息色-极浅 */
--color-info-50: #e6f7ff; /* 信息色-最浅 */
--color-info-100: #bae7ff; /* 信息色-浅 */
--color-info-500: #1890ff; /* 信息色-标准 */
--color-info-700: #096dd9; /* 信息色-深 */
--color-info-900: #0050b3; /* 信息色-最深 */
--color-accent-10: #f5fffe; /* 强调色-极浅 */
--color-accent-50: #e6fffb; /* 强调色-最浅 */
--color-accent-100: #87e8de; /* 强调色-浅 */
--color-accent-500: #13c2c2; /* 强调色-标准 */
--color-accent-700: #08979c; /* 强调色-深 */
--color-accent-900: #006d75; /* 强调色-最深 */
/* Chart Palette - 图表调色板 */
--chart-palette-1: var(--main-500);
--chart-palette-2: #5ad8a6;
--chart-palette-3: #f6bd16;
--chart-palette-4: #f27c7c;
--chart-palette-5: #9581cc;
--chart-palette-6: #6dc8ec;
--chart-palette-7: #ff9d4d;
--chart-palette-8: #92d050;
--chart-palette-9: #e885ba;
--chart-palette-10: #8c8c8c;
--bg-sider: var(--main-5);
--light-98: rgba(255, 255, 255, 0.98);
--light-95: rgba(255, 255, 255, 0.95);
--light-90: rgba(255, 255, 255, 0.9);
--light-85: rgba(255, 255, 255, 0.85);
--light-80: rgba(255, 255, 255, 0.8);
--light-75: rgba(255, 255, 255, 0.75);
--light-70: rgba(255, 255, 255, 0.7);
--light-50: rgba(255, 255, 255, 0.5);
--light-25: rgba(255, 255, 255, 0.25);
--light-10: rgba(255, 255, 255, 0.1);
--light-5: rgba(255, 255, 255, 0.05);
--light-0: rgba(255, 255, 255, 0);
--dark-98: rgba(0, 0, 0, 0.98);
--dark-95: rgba(0, 0, 0, 0.95);
--dark-90: rgba(0, 0, 0, 0.9);
--dark-85: rgba(0, 0, 0, 0.85);
--dark-80: rgba(0, 0, 0, 0.8);
--dark-75: rgba(0, 0, 0, 0.75);
--dark-70: rgba(0, 0, 0, 0.7);
--dark-50: rgba(0, 0, 0, 0.5);
--dark-25: rgba(0, 0, 0, 0.25);
--dark-10: rgba(0, 0, 0, 0.1);
--dark-5: rgba(0, 0, 0, 0.05);
--dark-0: rgba(0, 0, 0, 0);
/* Shadow System - 阴影系统 */
--shadow-0: rgba(0, 0, 0, 0.02);
--shadow-1: rgba(0, 0, 0, 0.05);
--shadow-2: rgba(0, 0, 0, 0.08);
--shadow-3: rgba(0, 0, 0, 0.12);
--shadow-4: rgba(0, 0, 0, 0.16);
--shadow-5: rgba(0, 0, 0, 0.2);
--min-width: 400px;
/* Page Padding - 响应式页面内边距 */
--page-padding: 22px;
/* Ant Design 兼容变量 */
--color-bg-container: var(--main-0);
--color-bg-elevated: var(--gray-10);
--color-text: var(--gray-1000);
--color-text-secondary: var(--dark-70);
--color-text-tertiary: var(--dark-50);
--color-trans-light: var(--light-85);
--color-trans-dark: var(--dark-85);
}
:root {
--scrollbar-width: 4px;
}
/* 全局滚动条样式 */
::-webkit-scrollbar {
width: var(--scrollbar-width);
height: var(--scrollbar-width);
}
::-webkit-scrollbar-track {
background: var(--gray-100);
border-radius: calc(var(--scrollbar-width) / 2);
}
::-webkit-scrollbar-thumb {
background: var(--gray-200);
border-radius: calc(var(--scrollbar-width) / 2);
transition: background 0.2s ease;
cursor: pointer;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gray-100);
}
/* Firefox 滚动条样式 */
* {
scrollbar-width: thin;
scrollbar-color: var(--gray-200) transparent;
}
/* Page Padding 响应式断点 */
@media (min-width: 1440px) {
:root {
--page-padding: 30px;
}
}
@media (max-width: 1199px) {
:root {
--page-padding: 18px;
}
}
@media (max-width: 767px) {
:root {
--page-padding: 14px;
}
}
+149
View File
@@ -0,0 +1,149 @@
/* 深色模式 */
:root.dark {
--main-1000: #e1f6fb;
--main-900: #c4eaf5;
--main-800: #a3d8e8;
--main-700: #82c3d6;
--main-600: #5faec2;
--main-500: #4a9fb8;
--main-400: #3996ae;
--main-300: #24839a;
--main-200: #046a82;
--main-100: #035065;
--main-50: #023944;
--main-40: #01151f;
--main-30: #010d14;
--main-20: #010a0f;
--main-10: #00070a;
--main-5: #000405;
--main-1: #000102;
--main-0: #000000;
/* 保留原有的 main 变量 */
--main-color: #4a9fb8;
--main-bright: #5faec2;
--gray-10000: #fafafa;
--gray-2000: #f8fafa;
--gray-1000: #f5f7f7;
--gray-900: #eff2f2;
--gray-800: #eef0f0;
--gray-700: #e4e6e6;
--gray-600: #d7d9d9;
--gray-500: #bdbfbf;
--gray-400: #979999;
--gray-300: #697070;
--gray-200: #4c4d4d;
--gray-150: #323333;
--gray-100: #1e1f1f;
--gray-50: #151616;
--gray-25: #0c0d0d;
--gray-10: #080808;
--gray-0: #030303;
--color-success-10: #0f1810; /* 成功色-极深 */
--color-success-50: #162312; /* 成功色-最深 */
--color-success-100: #273921; /* 成功色-深 */
--color-success-500: #52c41a; /* 成功色-标准 */
--color-success-700: #73d13d; /* 成功色-浅 */
--color-success-900: #95de64; /* 成功色-最浅 */
--color-error-10: #1a0a0b; /* 错误色-极深 */
--color-error-50: #2a1215; /* 错误色-最深 */
--color-error-100: #58181c; /* 错误色-深 */
--color-error-500: #ff4d4f; /* 错误色-标准 */
--color-error-700: #ff7875; /* 错误色-浅 */
--color-error-900: #ffa39e; /* 错误色-最浅 */
--color-warning-10: #1a1408; /* 警告色-极深 */
--color-warning-50: #2b2111; /* 警告色-最深 */
--color-warning-100: #614700; /* 警告色-深 */
--color-warning-500: #faad14; /* 警告色-标准 */
--color-warning-700: #ffc53d; /* 警告色-浅 */
--color-warning-900: #ffd666; /* 警告色-最浅 */
--color-info-10: #0c0d0d; /* 信息色-极深 */
--color-info-50: #111b26; /* 信息色-最深 */
--color-info-100: #111d2c; /* 信息色-深 */
--color-info-500: #3d8fff; /* 信息色-标准 */
--color-info-700: #40a9ff; /* 信息色-浅 */
--color-info-900: #69c0ff; /* 信息色-最浅 */
--color-accent-10: #081919; /* 强调色-极深 */
--color-accent-50: #0f2e2e; /* 强调色-最深 */
--color-accent-100: #164e63; /* 强调色-深 */
--color-accent-500: #13c2c2; /* 强调色-标准 */
--color-accent-700: #36cfc9; /* 强调色-浅 */
--color-accent-900: #5cdbd3; /* 强调色-最浅 */
--chart-palette-1: var(--main-300);
--chart-palette-2: #3ea896;
--chart-palette-3: #d6ad37;
--chart-palette-4: #cf6666;
--chart-palette-5: #8672b4;
--chart-palette-6: #529ab8;
--chart-palette-7: #d48242;
--chart-palette-8: #82b354;
--chart-palette-9: #c46e9e;
--chart-palette-10: #7a7a7a;
--bg-sider: #141414;
--color-text: #ffffff;
--dark-98: rgba(255, 255, 255, 0.98);
--dark-95: rgba(255, 255, 255, 0.95);
--dark-90: rgba(255, 255, 255, 0.9);
--dark-85: rgba(255, 255, 255, 0.85);
--dark-80: rgba(255, 255, 255, 0.8);
--dark-75: rgba(255, 255, 255, 0.75);
--dark-70: rgba(255, 255, 255, 0.7);
--dark-50: rgba(255, 255, 255, 0.5);
--dark-25: rgba(255, 255, 255, 0.25);
--dark-10: rgba(255, 255, 255, 0.1);
--dark-5: rgba(255, 255, 255, 0.05);
--dark-0: rgba(255, 255, 255, 0);
--light-98: rgba(0, 0, 0, 0.98);
--light-95: rgba(0, 0, 0, 0.95);
--light-90: rgba(0, 0, 0, 0.9);
--light-85: rgba(0, 0, 0, 0.85);
--light-80: rgba(0, 0, 0, 0.8);
--light-75: rgba(0, 0, 0, 0.75);
--light-70: rgba(0, 0, 0, 0.7);
--light-50: rgba(0, 0, 0, 0.5);
--light-25: rgba(0, 0, 0, 0.25);
--light-10: rgba(0, 0, 0, 0.1);
--light-5: rgba(0, 0, 0, 0.05);
--light-0: rgba(0, 0, 0, 0);
/* Ant Design 兼容变量 - 深色模式 */
--color-bg-container: #1f1f1f;
--color-bg-elevated: #262626;
--color-text-secondary: rgba(255, 255, 255, 0.65);
--color-text-tertiary: rgba(255, 255, 255, 0.45);
/* Shadow System - 深色模式阴影系统 */
--shadow-0: rgba(255, 255, 255, 0.02);
--shadow-1: rgba(255, 255, 255, 0.05);
--shadow-2: rgba(255, 255, 255, 0.08);
--shadow-3: rgba(255, 255, 255, 0.12);
--shadow-4: rgba(255, 255, 255, 0.16);
--shadow-5: rgba(255, 255, 255, 0.2);
--color-trans-light: rgba(0, 0, 0, 0.85);
--color-trans-dark: rgba(255, 255, 255, 0.85);
}
:root.dark .markmap {
--markmap-max-width: 9999px;
--markmap-a-color: var(--main-500);
--markmap-a-hover-color: var(--main-600);
--markmap-code-bg: var(--gray-50);
--markmap-code-color: var(--gray-700);
--markmap-highlight-bg: var(--color-warning-50);
--markmap-table-border: 1px solid currentColor;
--markmap-font: 300 16px / 20px sans-serif;
--markmap-circle-open-bg: var(--main-0);
--markmap-text-color: var(--gray-700);
--markmap-highlight-node-bg: var(--color-error-50);
}
+7
View File
@@ -0,0 +1,7 @@
.hljs-theme-light {
@import (less) '../../../node_modules/highlight.js/styles/github.css';
}
.hljs-theme-dark {
@import (less) '../../../node_modules/highlight.js/styles/github-dark.css';
}
+221
View File
@@ -0,0 +1,221 @@
/* Dashboard 共享样式文件 */
/* Chart Color System - 图表颜色系统 */
/* 注意:CSS 变量不支持对象语法,这些渐变定义仅作为参考 */
/* 如需在 ECharts 中使用渐变,请在 JavaScript 中配置 */
/* Chart Color Palette - 图表调色板 */
.chart-color-palette {
--palette-1: var(--chart-palette-1);
--palette-2: var(--chart-palette-2);
--palette-3: var(--chart-palette-3);
--palette-4: var(--chart-palette-4);
--palette-5: var(--chart-palette-5);
--palette-6: var(--chart-palette-6);
--palette-7: var(--chart-palette-7);
--palette-8: var(--chart-palette-8);
--palette-9: var(--chart-palette-9);
--palette-10: var(--chart-palette-10);
}
/* Chart Area Gradient - 图表区域渐变 */
.chart-area-gradient-primary {
background: linear-gradient(180deg, rgba(57, 150, 174, 0.3) 0%, rgba(57, 150, 174, 0.05) 100%);
}
.chart-area-gradient-success {
background: linear-gradient(180deg, rgba(82, 196, 26, 0.3) 0%, rgba(82, 196, 26, 0.05) 100%);
}
.chart-area-gradient-warning {
background: linear-gradient(180deg, rgba(250, 173, 20, 0.3) 0%, rgba(250, 173, 20, 0.05) 100%);
}
.chart-area-gradient-error {
background: linear-gradient(180deg, rgba(245, 34, 45, 0.3) 0%, rgba(245, 34, 45, 0.05) 100%);
}
/* Chart Shadow Effects - 图表阴影效果 */
.chart-shadow-primary {
box-shadow: 0 4px 12px rgba(57, 150, 174, 0.15);
}
.chart-shadow-success {
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.15);
}
.chart-shadow-warning {
box-shadow: 0 4px 12px rgba(250, 173, 20, 0.15);
}
.chart-shadow-error {
box-shadow: 0 4px 12px rgba(245, 34, 45, 0.15);
}
/* 共享的卡片样式 */
.dashboard-card {
background-color: var(--gray-0);
border: 1px solid var(--gray-100);
border-radius: 8px;
transition: all 0.2s ease;
height: 100%;
display: flex;
flex-direction: column;
&:hover {
border-color: var(--gray-200);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
}
/* 使用更具体的选择器来覆盖 Ant Design 的默认样式 */
.dashboard-card .ant-card-head {
/* border-bottom: 1px solid var(--gray-200) !important; */
min-height: 56px !important;
padding: 0 20px !important;
flex-shrink: 0;
background-color: var(--gray-0) !important;
/* border: 1px solo; */
}
.dashboard-card .ant-card-head-title {
font-size: 18px !important;
font-weight: 600 !important;
color: var(--gray-1000) !important;
}
.dashboard-card .ant-card-body {
padding: 20px !important;
padding-top: 0px !important;
flex: 1;
overflow: auto;
background-color: var(--gray-0) !important;
}
.dashboard-card .ant-card-extra {
padding-top: 0px !important;
}
/* 统计概览样式 */
.stats-overview {
margin-bottom: 16px;
.ant-statistic {
.ant-statistic-title {
font-size: 13px;
font-weight: 500;
color: var(--gray-600);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.ant-statistic-content {
font-size: 24px;
font-weight: 700;
color: var(--gray-1000);
}
}
}
/* 图表容器样式 */
.chart-container {
h4,
h5 {
margin-bottom: 16px;
font-weight: 600;
color: var(--gray-1000);
}
h4 {
font-size: 16px;
}
h5 {
font-size: 14px;
color: var(--gray-600);
}
.chart {
height: 250px;
width: 100%;
background-color: var(--gray-0);
border-radius: 8px;
padding: 10px;
border: 1px solid var(--gray-100);
}
.chart-medium {
height: 180px;
width: 100%;
background-color: var(--gray-0);
border-radius: 8px;
padding: 8px;
border: 1px solid var(--gray-100);
}
.chart-small {
height: 180px;
width: 100%;
}
}
/* Ant Design 组件样式覆盖 */
.dashboard-card .ant-statistic-title {
color: var(--gray-600) !important;
font-weight: 500 !important;
}
.dashboard-card .ant-statistic-content {
color: var(--gray-1000);
}
/* 强制设置所有统计卡片数字为粗体 */
.dashboard-card .ant-statistic-content-value {
font-weight: 700 !important;
}
.dashboard-card .ant-table {
background-color: var(--gray-0);
border-radius: 8px;
overflow: hidden;
.ant-table-thead > tr > th {
background-color: var(--gray-25);
border-bottom: 1px solid var(--gray-200);
font-weight: 600;
padding: 12px 16px;
font-size: 12px;
color: var(--gray-700);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ant-table-tbody > tr {
transition: background-color 0.2s ease;
> td {
border-bottom: 1px solid var(--gray-100);
color: var(--gray-1000);
padding: 12px 16px;
font-weight: 400;
}
&:hover > td {
background-color: var(--gray-25);
}
}
}
.dashboard-card .ant-progress {
.ant-progress-bg {
background-color: var(--main-500) !important;
border-radius: 4px;
}
.ant-progress-text {
color: var(--gray-1000) !important;
font-weight: 500;
font-size: 12px;
}
}
+96
View File
@@ -0,0 +1,96 @@
.extension-detail-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--gray-0);
.detail-top-bar {
padding: 7px var(--page-padding);
display: flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--gray-100);
flex-shrink: 0;
background-color: var(--gray-0);
}
.detail-back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: none;
color: var(--gray-500);
font-size: 14px;
cursor: pointer;
padding: 4px 8px;
margin-left: -8px;
border-radius: 6px;
transition:
color 0.15s,
background 0.15s;
&:hover {
color: var(--gray-700);
background: var(--gray-50);
}
}
.detail-title-area {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.detail-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--main-30);
border: 1px solid var(--gray-150);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--main-color);
font-size: 18px;
}
.detail-title-text {
display: flex;
flex-direction: column;
min-width: 0;
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-subtitle {
font-size: 12px;
color: var(--gray-500);
}
}
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.detail-content-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
}
+838
View File
@@ -0,0 +1,838 @@
@border-color: var(--gray-150);
@bg-secondary: var(--gray-10);
@radius: 8px;
@mono-font: 'Monaco', 'Consolas', monospace;
// Root container for extension pages to ensure 100% height
.extension-page-root {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background-color: var(--gray-0);
position: relative;
}
.layout-wrapper {
display: flex;
flex: 1;
min-height: 0;
height: 100%;
}
/* Sidebar List */
.sidebar-list {
width: 280px;
border-right: 1px solid @border-color;
background-color: @bg-secondary;
display: flex;
flex-direction: column;
flex-shrink: 0;
.sidebar-toolbar {
padding: 8px var(--page-padding) 0;
display: flex;
align-items: center;
gap: 8px;
.search-box {
flex: 1;
min-width: 0;
padding: 0;
}
.search-box :deep(.ant-input-affix-wrapper) {
height: 28px;
padding: 0 10px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background-color: var(--gray-0);
box-shadow: none;
&:hover,
&:focus,
&.ant-input-affix-wrapper-focused {
border-color: var(--gray-200);
box-shadow: none;
}
}
}
.search-box {
padding: 8px var(--page-padding) 0;
:deep(.ant-input-affix-wrapper) {
height: 28px;
padding: 0 10px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background-color: var(--gray-0);
box-shadow: none;
&:hover,
&:focus,
&.ant-input-affix-wrapper-focused {
border-color: var(--gray-200);
box-shadow: none;
}
}
:deep(.ant-input-prefix) {
margin-right: 8px;
color: var(--gray-400);
}
:deep(.ant-input) {
height: 100%;
background-color: transparent;
}
:deep(.ant-input::placeholder) {
color: var(--gray-400);
}
:deep(.ant-input-clear-icon) {
color: var(--gray-400);
}
:deep(.ant-input-clear-icon:hover) {
color: var(--gray-500);
}
:deep(.ant-input-outlined) {
border-color: var(--gray-150);
}
}
.sidebar-tool {
width: 28px;
height: 28px;
padding: 0;
border-radius: 8px;
border-color: var(--gray-150);
background-color: var(--gray-0);
color: var(--gray-500);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: none;
&:hover,
&:focus {
color: var(--gray-700);
border-color: var(--gray-200);
background-color: var(--gray-0);
}
&.active {
color: var(--main-color);
border-color: var(--main-color);
background-color: color-mix(in srgb, var(--main-color) 8%, var(--gray-0));
}
}
.list-container {
flex: 1;
overflow-y: auto;
padding: 12px var(--page-padding);
display: flex;
flex-direction: column;
gap: 8px;
}
}
.list-item {
padding: 10px 12px;
border-radius: @radius;
cursor: pointer;
transition: all 0.2s;
border: 1px solid var(--gray-100);
background-color: var(--gray-0);
display: flex;
flex-direction: column;
gap: 12px;
&:hover {
border-color: @border-color;
box-shadow: 0 1px 2px var(--shadow-1);
}
&.active {
background-color: var(--gray-0);
border-color: var(--main-500);
}
.item-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
.item-icon,
.server-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--gray-700);
font-size: 14px; // For emoji icons
}
.item-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--gray-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.item-details {
display: flex;
// flex-direction: column;
gap: 4px;
}
.item-desc {
font-size: 12px;
color: var(--gray-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.active {
.item-icon,
.server-icon {
color: var(--main-color);
}
.item-name {
color: var(--main-700);
}
}
}
.list-item .item-details-inline {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.list-section-title {
padding: 0px 14px;
color: var(--gray-500);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.extension-list-item {
.item-main-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.item-header {
min-width: 0;
margin-bottom: 0;
}
.item-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-status {
position: relative;
flex-shrink: 0;
}
.item-details {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.item-desc {
flex: 1;
min-width: 0;
}
.item-tags {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
background: var(--gray-100);
color: var(--gray-600);
font-size: 11px;
font-weight: 600;
&.warning {
background: var(--color-warning-50);
color: var(--color-warning-900);
}
&-success {
background: var(--color-success-50);
color: var(--color-success-700);
}
}
.inline-hover-action {
display: none;
min-height: 24px;
padding: 0 9px;
border: 0;
border-radius: 999px;
background: var(--color-error-50);
color: var(--color-error-700);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
&:hover .inline-hover-action {
display: inline-flex;
align-items: center;
justify-content: center;
}
&:hover .status-chip {
display: none;
}
.source-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 20px;
padding: 0 8px;
border-radius: 999px;
background: var(--color-info-50);
color: var(--color-info-700);
font-size: 11px;
font-weight: 600;
line-height: 1;
&.builtin {
background: var(--gray-50);
color: var(--gray-600);
}
}
}
.skill-inline-action {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
min-width: 52px;
min-height: 24px;
padding: 0 9px;
border: 1px solid transparent;
box-shadow: none;
font-size: 11px;
font-weight: 600;
line-height: 1;
transition:
background-color 0.18s ease,
border-color 0.18s ease,
color 0.18s ease;
cursor: pointer;
appearance: none;
&:hover,
&:focus {
outline: none;
}
&.skill-inline-action-primary {
// border-color: var(--main-100);
background: var(--main-50);
color: var(--main-700);
}
&.skill-inline-action-secondary {
border-color: var(--main-100);
background: var(--main-30);
color: var(--main-700);
}
&.skill-inline-action-primary:hover,
&.skill-inline-action-primary:focus {
border-color: var(--main-200);
background: var(--main-50);
color: var(--main-800);
}
&.skill-inline-action-secondary:hover,
&.skill-inline-action-secondary:focus {
border-color: var(--main-200);
background: var(--main-50);
color: var(--main-800);
}
}
.extension-panel-action {
min-height: 30px;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border-style: solid;
border-width: 1px;
box-shadow: none;
font-weight: 500;
transition:
background-color 0.18s ease,
border-color 0.18s ease,
color 0.18s ease;
cursor: pointer;
appearance: none;
&:hover,
&:focus {
outline: none;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&.extension-panel-action-primary {
border-color: transparent;
background: var(--main-600);
color: var(--main-0);
}
&.extension-panel-action-primary:hover,
&.extension-panel-action-primary:focus {
background: var(--main-700);
color: var(--main-0);
}
&.extension-panel-action-secondary {
border-color: var(--gray-200);
background: var(--gray-0);
color: var(--gray-700);
&:hover,
&:focus {
border-color: var(--gray-300);
color: var(--gray-900);
background: var(--gray-0);
}
}
&.extension-panel-action-danger {
border-color: var(--color-error-100);
background: var(--color-error-50);
color: var(--color-error-700);
&:hover,
&:focus {
border-color: var(--color-error-100);
background: var(--color-error-100);
color: var(--color-error-900);
}
}
}
.item-meta {
font-size: 12px;
color: var(--gray-700);
}
.list-separator {
display: none;
}
/* Main Panel */
.main-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background-color: var(--gray-0);
.unselected-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: @bg-secondary;
.hint-box {
text-align: center;
p {
margin-top: 12px;
color: var(--gray-400);
}
}
}
.panel-top-bar {
padding: 10px 16px 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.panel-title-row {
min-height: 32px;
}
.panel-title-stack {
min-height: 32px;
display: flex;
flex-direction: column;
justify-content: center;
}
.panel-title-meta {
font-size: 12px;
color: var(--gray-500);
border-radius: 4px;
margin-top: 4px;
display: inline-block;
user-select: text;
}
.panel-title-icon {
color: var(--gray-700);
flex-shrink: 0;
}
.panel-actions {
display: flex;
align-items: center;
}
}
}
/* Tabs inside main panel */
.detail-tabs,
.minimal-tabs {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
:deep(.ant-tabs-nav) {
margin: 0;
padding: 0 var(--page-padding);
flex-shrink: 0;
border-bottom: 1px solid @border-color;
&::before {
border-bottom: none;
}
}
:deep(.ant-tabs-content) {
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
min-height: 0;
overflow: hidden;
}
}
.detail-tabs {
.tab-content {
padding: 16px;
min-height: 300px;
height: 100%;
overflow: auto;
}
.empty-tab {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
}
.tab-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.info-grid {
display: grid;
gap: 16px;
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--gray-500);
font-weight: 500;
}
> span {
font-size: 14px;
color: var(--gray-900);
}
}
}
.extension-form {
.form-item {
margin-bottom: 16px;
}
}
.mono-text {
font-family: @mono-font;
}
.code-inline {
font-family: @mono-font;
font-size: 13px;
background: var(--gray-50);
padding: 8px 12px;
border-radius: 4px;
}
.code-pre {
font-family: @mono-font;
font-size: 12px;
background: var(--gray-50);
padding: 12px;
border-radius: 4px;
margin: 0;
overflow-x: auto;
}
.code-panel {
background: var(--gray-50);
border: 1px solid var(--gray-150);
border-radius: @radius;
padding: 16px;
}
.code-panel-pre {
margin: 0;
font-family: @mono-font;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
color: var(--gray-700);
}
.text-break-all {
word-break: break-all;
}
/* Common helpers */
.text-muted {
color: var(--gray-400);
}
.empty-text {
padding: 40px 0;
text-align: center;
color: var(--gray-400);
}
// Spin wrapper for full height
.full-height-spin {
height: 100%;
:deep(.ant-spin-nested-loading) {
height: 100%;
}
:deep(.ant-spin-container) {
height: 100%;
display: flex;
flex-direction: column;
}
}
/* Loading States */
.loading-bar-wrapper {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
z-index: 100;
overflow: hidden;
background: transparent;
.loading-bar {
height: 100%;
background: var(--main-color);
width: 30%;
position: absolute;
animation: loading-bar-anim 1.5s infinite linear;
}
}
@keyframes loading-bar-anim {
0% {
left: -30%;
}
100% {
left: 100%;
}
}
.content-loading {
opacity: 0.6;
pointer-events: none;
transition: opacity 0.2s;
}
/* Detail sections - shared between Tools and SubAgents */
.detail-section-container {
padding: 16px;
flex: 1;
overflow-y: auto;
.detail-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.section-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 8px;
svg {
color: var(--gray-500);
}
}
.section-content {
font-size: 13px;
color: var(--gray-600);
line-height: 1.6;
&.description {
white-space: pre-wrap;
word-break: break-word;
}
&.meta-info {
.meta-item {
display: flex;
gap: 12px;
font-size: 13px;
color: var(--gray-600);
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
.meta-label {
color: var(--gray-500);
min-width: 60px;
}
.meta-value {
color: var(--gray-700);
}
}
}
}
}
}
.extension-section-header {
padding: 12px var(--page-padding) 0;
color: var(--gray-500);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.4px;
}
.extension-card-grid-empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 100px 20px;
text-align: center;
}
.status-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
background: var(--gray-100);
color: var(--gray-600);
font-size: 11px;
font-weight: 600;
&.warning {
background: var(--color-warning-50);
color: var(--color-warning-900);
}
&-success {
background: var(--color-success-50);
color: var(--color-success-700);
}
}
+110
View File
@@ -0,0 +1,110 @@
@import './base.css';
@import './base.dark.css';
@import './sigma.css';
@import './shorts.css';
@import './dashboard.css';
@import './code-highlight.less';
:root {
--header-height: 45px;
--min-width: 450px;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
position: relative;
font-weight: normal;
}
body {
display: flow-root;
min-height: 100vh;
color: var(--gray-900);
background: var(--gray-0);
line-height: 1.6;
font-family:
-apple-system, BlinkMacSystemFont, 'Noto Sans SC', 'Roboto', 'HarmonyOS Sans SC', 'Segoe UI',
'Helvetica Neue', Arial, sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* layout */
.layout-container {
width: 100%;
h2 {
margin: 20px 0 10px 0;
}
}
.lucide-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.lucide-icon-btn svg {
flex-shrink: 0;
}
.ant-btn.lucide-icon-btn,
.lucide-icon-btn.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.ant-btn .ant-btn-icon,
.ant-btn .anticon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.card-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
line-height: 1;
background: var(--gray-100);
color: var(--gray-600);
border-radius: 4px;
}
.card-tag.tag-purple,
.card-tag.tag-cyan {
background: var(--color-accent-50);
color: var(--color-accent-700);
}
.card-tag.tag-blue {
background: var(--color-info-50);
color: var(--color-info-700);
}
.card-tag.tag-red,
.card-tag.tag-orange {
background: var(--color-error-50);
color: var(--color-error-700);
}
.card-tag.tag-green {
background: var(--color-success-50);
color: var(--color-success-700);
}
.card-tag.tag-gold {
background: var(--color-warning-50);
color: var(--color-warning-900);
}
@@ -0,0 +1,140 @@
// 模型选择器公共样式 - 供 Chat/Embedding/Rerank 三个模型选择器共用
@scrollbar-width: 6px;
@border-radius: 8px;
// 下拉触发器样式
.model-select {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 4px 8px;
cursor: pointer;
border: 1px solid var(--gray-200);
border-radius: @border-radius;
background-color: var(--gray-0);
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
&.borderless {
border: none;
}
&.max-width {
max-width: 380px;
}
&.model-select--middle {
font-size: 15px;
}
&.model-select--large {
font-size: 16px;
}
}
// 内容布局
.model-select-content {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
// 模型名称区域
.model-info {
display: flex;
align-items: center;
flex: 1;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.model-text {
display: block;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--gray-1000);
white-space: nowrap;
}
// 状态控制区域(检查按钮、刷新按钮等)
.model-status-controls {
display: flex;
align-items: center;
gap: 4px;
flex: 0;
margin-left: auto;
}
// 状态指示器
.model-status-indicator {
font-size: 11px;
font-weight: bold;
padding: 2px 4px;
border-radius: 3px;
&.available {
color: var(--color-success-500);
}
&.unavailable {
color: var(--color-error-500);
}
&.error {
color: var(--color-warning-500);
}
}
// Provider 标签样式
.provider-tag {
padding: 2px 4px;
margin-left: 6px;
font-size: 10px;
line-height: 1;
vertical-align: middle;
}
// 刷新图标旋转动画
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 滚动菜单样式
.scrollable-menu {
max-height: 300px;
overflow-y: auto;
&::-webkit-scrollbar {
width: @scrollbar-width;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--gray-400);
border-radius: 3px;
&:hover {
background: var(--gray-500);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
.m-2 {
margin: 8px;
}
.mt-2 {
margin-top: 8px;
}
.mb-2 {
margin-bottom: 8px;
}
.ml-2 {
margin-left: 8px;
}
.mr-2 {
margin-right: 8px;
}
.m-3 {
margin: 12px;
}
.mt-3 {
margin-top: 12px;
}
.mb-3 {
margin-bottom: 12px;
}
.ml-3 {
margin-left: 12px;
}
.mr-3 {
margin-right: 12px;
}
.bn-1px {
position: relative;
bottom: -1px;
}
+87
View File
@@ -0,0 +1,87 @@
/* Sigma.js 基础样式 */
.sigma-container {
position: relative;
width: 100%;
height: 100%;
}
.sigma-container canvas {
outline: none;
}
/* 节点标签样式 */
.sigma-labels {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* 高亮样式 */
.node-highlighted {
stroke: var(--color-error-500);
stroke-width: 3px;
}
.edge-highlighted {
stroke: var(--color-error-500);
stroke-width: 3px;
}
/* 选中样式 */
.node-selected {
stroke: var(--color-info-500);
stroke-width: 4px;
}
.edge-selected {
stroke: var(--color-info-500);
stroke-width: 4px;
}
/* 悬停样式 */
.node-focused {
stroke: var(--color-success-500);
stroke-width: 2px;
}
.edge-focused {
stroke: var(--color-success-500);
stroke-width: 2px;
}
/* 加载动画 */
.sigma-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
}
/* 工具提示 */
.sigma-tooltip {
position: absolute;
background: var(--shadow-5);
color: var(--gray-0);
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
/* 响应式调整 */
@media (max-width: 768px) {
.sigma-container {
font-size: 10px;
}
.sigma-tooltip {
font-size: 10px;
padding: 6px 8px;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592124579" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3188" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H170.666667c-62.862222 0-113.777778-50.915556-113.777778-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#FFC63A" p-id="3189"></path><path d="M685.511111 167.822222V0L967.111111 281.6H799.288889c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#DD9F08" p-id="3190"></path><path d="M436.565333 68.437333h68.437334V0h-68.437334zM505.002667 136.874667h68.437333V68.437333h-68.437333zM436.565333 205.312h68.437334V136.874667h-68.437334zM505.002667 273.749333h68.437333V205.312h-68.437333z" fill="#FFFFFF" p-id="3191"></path><path d="M436.565333 342.158222h68.437334V273.720889h-68.437334zM505.002667 410.624h68.437333V342.186667h-68.437333z" fill="#FFFFFF" p-id="3192"></path><path d="M436.565333 479.032889h68.437334v-68.437333h-68.437334zM505.002667 547.470222h68.437333v-68.437333h-68.437333zM470.784 762.225778h68.437333v-68.437334h-68.437333v68.437334z m-34.218667-136.874667v136.874667c0 18.915556 15.331556 34.218667 34.218667 34.218666h68.437333c18.915556 0 34.218667-15.303111 34.218667-34.218666v-136.874667h-136.874667z" fill="#FFFFFF" p-id="3193"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592122103" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3032" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H170.666667c-62.862222 0-113.777778-50.915556-113.777778-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#A15FDE" p-id="3033"></path><path d="M685.511111 196.266667V0L967.111111 281.6H770.844444a85.333333 85.333333 0 0 1-85.333333-85.333333" fill="#C386F0" p-id="3034"></path><path d="M669.980444 426.268444v236.999112c0 26.254222-31.857778 47.587556-71.082666 47.587555-39.253333 0-70.741333-21.333333-70.741334-47.587555 0-26.282667 31.516444-47.587556 70.741334-47.587556 14.848 0 28.728889 3.100444 40.163555 8.334222v-165.916444l-205.767111 48.497778v211.057777c0 26.254222-32.142222 47.559111-71.992889 47.559111-39.850667 0-72.305778-21.333333-72.305777-47.559111 0-26.282667 32.426667-47.587556 72.305777-47.587555a96.711111 96.711111 0 0 1 41.102223 8.647111V474.168889c0-14.222222 9.870222-26.88 23.779555-29.980445l205.795556-47.900444a30.862222 30.862222 0 0 1 38.001777 29.980444" fill="#FFFFFF" p-id="3035"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592136353" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3970" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.577778-51.2 113.777778-113.777778 113.777778H170.666667c-62.577778 0-113.777778-51.2-113.777778-113.777778V113.777778c0-62.577778 51.2-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#4997D9" p-id="3971"></path><path d="M685.511111 224.711111V0L967.111111 281.6H742.4c-31.288889 0-56.888889-25.6-56.888889-56.888889" fill="#1973BE" p-id="3972"></path><path d="M260.920889 499.655111c8.931556 0 16.810667-0.910222 23.608889-2.730667s12.060444-3.84 15.786666-6.115555L292.977778 469.617778l-4.920889 2.218666a42.808889 42.808889 0 0 1-6.257778 2.104889 71.196444 71.196444 0 0 1-18.488889 2.275556c-6.144 0-11.377778-0.967111-15.644444-2.958222a27.704889 27.704889 0 0 1-10.467556-8.049778 32.227556 32.227556 0 0 1-5.774222-12.145778 61.809778 61.809778 0 0 1-1.763556-15.189333c0-6.798222 0.881778-12.572444 2.702223-17.351111 1.820444-4.721778 4.209778-8.618667 7.224889-11.605334a27.079111 27.079111 0 0 1 10.353777-6.542222c3.896889-1.365333 7.907556-2.019556 11.946667-2.019555a60.359111 60.359111 0 0 1 29.070222 6.940444l7.623111-21.390222a52.792889 52.792889 0 0 0-12.060444-5.347556 88.064 88.064 0 0 0-10.695111-2.56 76.743111 76.743111 0 0 0-13.568-1.080889c-8.618667 0-16.583111 1.365333-23.950222 4.067556a52.536889 52.536889 0 0 0-19.029334 11.946667 56.149333 56.149333 0 0 0-12.544 19.370666 69.717333 69.717333 0 0 0-4.579555 26.083556c0 19.569778 5.006222 34.702222 15.018666 45.340444 10.012444 10.638222 24.576 15.928889 43.719111 15.928889z m75.946667-2.389333c1.223111-4.067556 2.616889-8.248889 4.124444-12.572445 1.536-4.266667 3.043556-8.590222 4.522667-12.885333h45.824l4.522666 12.885333c1.507556 4.323556 2.901333 8.504889 4.152889 12.572445h28.359111a2094.193778 2094.193778 0 0 0-12.572444-35.157334c-3.982222-10.752-7.850667-20.878222-11.633778-30.378666a901.973333 901.973333 0 0 0-22.670222-52.138667h-25.315556c-3.953778 8.163556-7.765333 16.497778-11.434666 25.031111a1087.004444 1087.004444 0 0 0-22.840889 57.486222c-3.982222 10.752-8.163556 22.471111-12.572445 35.157334h27.505778z m48.184888-46.535111h-33.450666c1.820444-5.091556 3.612444-9.955556 5.376-14.592a3646.151111 3646.151111 0 0 1 8.732444-22.926223c1.137778-2.929778 1.991111-5.262222 2.56-6.968888l2.702222 6.968888 3.982223 10.183112c1.536 3.868444 3.128889 8.106667 4.835555 12.743111 1.706667 4.636444 3.470222 9.500444 5.262222 14.592z m97.962667 48.071111c10.439111 0 19.825778-1.251556 28.188445-3.754667 8.391111-2.474667 15.502222-6.200889 21.333333-11.207111 5.802667-4.977778 10.325333-11.235556 13.482667-18.830222 3.185778-7.594667 4.750222-16.497778 4.750222-26.652445 0-10.638222-1.564444-19.768889-4.750222-27.335111a47.530667 47.530667 0 0 0-13.568-18.688 56.092444 56.092444 0 0 0-21.162667-10.695111c-8.192-2.275556-17.351111-3.413333-27.420445-3.413333-4.750222 0-10.439111 0.199111-17.066666 0.597333-6.599111 0.398222-12.970667 1.166222-19.086222 2.304v114.631111c6.115556 1.223111 12.344889 2.048 18.659555 2.446222 6.343111 0.398222 11.889778 0.568889 16.64 0.568889z m1.365333-22.755556a173.283556 173.283556 0 0 1-10.183111-0.341333v-74.382222c1.706667-0.227556 3.498667-0.369778 5.432889-0.426667l5.603556-0.085333c13.255111 0 22.926222 3.185778 29.013333 9.528889 6.144 6.343111 9.187556 15.644444 9.187556 28.017777 0 11.776-3.185778 20.992-9.585778 27.676445-6.4 6.656-16.213333 10.012444-29.468445 10.012444z" fill="#FFFFFF" p-id="3973"></path><path d="M191.687111 705.422222h426.666667v-31.288889h-426.666667zM696.689778 705.422222H853.333333v-31.288889h-156.643555zM641.991111 650.723556h31.288889V337.408h-31.288889zM641.991111 885.674667h31.288889V729.031111h-31.288889z" fill="#FFFFFF" p-id="3974"></path><path d="M618.382222 729.031111h78.307556v-78.307555H618.382222v78.307555z m91.363556-104.419555H605.297778a12.942222 12.942222 0 0 0-13.056 13.056v104.419555c0 7.338667 5.745778 13.056 13.056 13.056h104.448a12.942222 12.942222 0 0 0 13.056-13.056v-104.419555a12.942222 12.942222 0 0 0-13.056-13.056z" fill="#FFFFFF" p-id="3975"></path></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592174762" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5343" width="48" height="48" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M967.111111 281.6V910.222222c0 62.577778-51.2 113.777778-113.777778 113.777778H170.666667c-62.577778 0-113.777778-51.2-113.777778-113.777778V113.777778c0-62.577778 51.2-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#BABABA" p-id="5344"></path><path d="M632.234667 468.48a21.333333 21.333333 0 0 1 27.989333-1.905778l2.190222 1.905778 100.551111 100.551111a21.333333 21.333333 0 0 1 1.905778 27.989333l-1.905778 2.190223-100.551111 100.551111a21.333333 21.333333 0 0 1-32.085333-27.960889l1.905778-2.190222 85.475555-85.504-85.475555-85.475556a21.333333 21.333333 0 0 1-1.905778-27.989333l1.905778-2.161778zM377.969778 699.761778a21.333333 21.333333 0 0 1-27.989334 1.934222l-2.190222-1.934222-100.579555-100.551111a21.333333 21.333333 0 0 1-1.905778-27.989334l1.905778-2.190222 100.579555-100.551111a21.333333 21.333333 0 0 1 32.085334 27.989333l-1.905778 2.161778-85.475556 85.504 85.475556 85.475556a21.333333 21.333333 0 0 1 1.905778 27.989333l-1.905778 2.161778zM527.047111 475.392a21.333333 21.333333 0 0 1 40.334222 13.568l-0.910222 2.759111-83.342222 201.130667a21.333333 21.333333 0 0 1-40.334222-13.568l0.938666-2.759111 83.313778-201.130667z" fill="#FFFFFF" p-id="5345"></path><path d="M685.511111 224.711111V0L967.111111 281.6H742.4c-31.288889 0-56.888889-25.6-56.888889-56.888889" fill="#979797" p-id="5346"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592142627" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4288" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H170.666667c-62.862222 0-113.777778-50.915556-113.777778-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#BABABA" p-id="4289"></path><path d="M685.511111 167.822222V0L967.111111 281.6H799.288889c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#979797" p-id="4290"></path><path d="M733.667556 632.689778a111.104 111.104 0 0 1-110.819556 110.819555h-221.667556a111.132444 111.132444 0 0 1-110.848-110.819555 111.047111 111.047111 0 0 1 99.754667-110.279111A122.197333 122.197333 0 0 1 512 407.694222a122.197333 122.197333 0 0 1 121.912889 114.716445 111.160889 111.160889 0 0 1 99.754667 110.279111" fill="#FFFFFF" p-id="4291"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" standalone="no"?>
<svg class="icon" viewBox="0 0 1228 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="239.84375" height="200">
<path d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728" fill="#155d70" />
<path d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667" fill="#24839b" />
<path d="M634.88 399.36v52.565333h100.010667c25.941333 0 47.104 21.162667 47.104 47.104v28.330667h24.234666c19.114667 0 34.133333 15.36 34.133334 34.133333v98.986667c0 18.773333-15.018667 34.133333-34.133334 34.133333h-24.234666v28.330667c0 25.941333-21.162667 47.104-47.104 47.104H493.909333c-25.941333 0-47.104-21.162667-47.104-47.104v-28.330667h-24.234666c-19.114667 0-34.133333-15.36-34.133334-34.133333v-98.986667c0-18.773333 15.018667-34.133333 34.133334-34.133333h24.234666v-28.330667c0-25.941333 21.162667-47.104 47.104-47.104h100.010667V399.36a20.48 20.48 0 0 1 40.96 0z" fill="#FFFFFF" />
<path d="M527.701333 590.848a38.570667 38.570667 0 1 0 77.141334 0 38.570667 38.570667 0 0 0-77.141334 0zM624.981333 675.84c0-9.557333 7.509333-17.066667 17.066667-17.066667h69.632c9.557333 0 17.066667 7.509333 17.066667 17.066667s-7.509333 17.066667-17.066667 17.066667h-69.632c-9.557333 0-17.066667-7.509333-17.066667-17.066667zM681.984 590.848a38.570667 38.570667 0 1 0 77.141333 0 38.570667 38.570667 0 0 0-77.141333 0z" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592075491" class="icon" viewBox="0 0 1228 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1143" xmlns:xlink="http://www.w3.org/1999/xlink" width="239.84375" height="200"><path d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728" fill="#155d70" p-id="1144"></path><path d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667" fill="#24839b" p-id="1145"></path><path d="M553.984 643.072c0-7.509333 6.144-13.653333 13.653333-13.653333h16.042667c7.509333 0 13.994667 6.144 13.994667 13.653333v16.042667c0 7.509333-6.485333 13.653333-13.994667 13.653333h-16.042667c-7.509333 0-13.653333-6.144-13.653333-13.653333v-16.042667z m0-76.117333c0-7.850667 6.144-13.994667 13.653333-13.994667h16.042667c7.509333 0 13.994667 6.144 13.994667 13.994667v15.701333c0 7.850667-6.485333 13.994667-13.994667 13.994667h-16.042667c-7.509333 0-13.653333-6.144-13.653333-13.994667v-15.701333z m0-76.458667c0-7.509333 6.144-13.653333 13.653333-13.653333h16.042667c7.509333 0 13.994667 6.144 13.994667 13.653333v16.042667c0 7.509333-6.485333 13.994667-13.994667 13.994666h-16.042667a13.994667 13.994667 0 0 1-13.653333-13.994666v-16.042667z m79.189333 152.576c0-7.509333 6.485333-13.653333 13.994667-13.653333h16.042667c7.509333 0 13.653333 6.144 13.653333 13.653333v16.042667c0 7.509333-6.144 13.653333-13.653333 13.653333h-16.042667a13.994667 13.994667 0 0 1-13.994667-13.653333v-16.042667z m0-76.117333c0-7.850667 6.485333-13.994667 13.994667-13.994667h16.042667c7.509333 0 13.653333 6.144 13.653333 13.994667v15.701333c0 7.850667-6.144 13.994667-13.653333 13.994667h-16.042667c-7.509333 0-13.994667-6.144-13.994667-13.994667v-15.701333z m0-76.458667c0-7.509333 6.485333-13.653333 13.994667-13.653333h16.042667c7.509333 0 13.653333 6.144 13.653333 13.653333v16.042667c0 7.509333-6.144 13.994667-13.653333 13.994666h-16.042667c-7.509333 0-13.994667-6.485333-13.994667-13.994666v-16.042667z m139.946667 233.472h-24.917333V446.464C748.202667 425.984 731.477333 409.6 711.338667 409.6H520.533333c-20.138667 0-36.864 16.384-36.864 36.864v277.504H458.752c-8.533333 0-15.018667 6.826667-15.018667 15.018667v23.210666c0 8.192 6.485333 15.018667 15.018667 15.018667h314.368c8.533333 0 15.36-6.826667 15.36-15.018667v-23.210666c0-8.192-6.826667-15.018667-15.36-15.018667z" fill="#FFFFFF" p-id="1146"></path></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592091141" class="icon" viewBox="0 0 1228 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1301" xmlns:xlink="http://www.w3.org/1999/xlink" width="239.84375" height="200"><path d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728" fill="#155d70" p-id="1302"></path><path d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667" fill="#24839b" p-id="1303"></path><path d="M646.826667 426.666667L689.834667 513.706667l96.1536 13.994666a30.6176 30.6176 0 0 1 16.964266 52.155734L733.3888 647.68l16.384 95.744a30.583467 30.583467 0 0 1-44.373333 32.256L619.485867 730.453333l-86.016 45.226667a30.583467 30.583467 0 0 1-44.373334-32.256l16.452267-95.744L435.950933 579.925333A30.6176 30.6176 0 0 1 452.881067 527.701333l96.1536-13.960533L592.042667 426.666667A30.549333 30.549333 0 0 1 646.826667 426.666667" fill="#FFFFFF" p-id="1304"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" standalone="no"?>
<svg class="icon" viewBox="0 0 1228 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="239.84375" height="200">
<path d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728" fill="#155d70" />
<path d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667" fill="#24839b" />
<path d="M438.613333 450.901333c0-24.576 19.797333-44.373333 44.373334-44.373333h103.253333c28.330667 0 55.637333 9.557333 77.141333 27.306667 21.504-17.749333 48.810667-27.306667 77.141334-27.306667h103.253333c24.576 0 44.373333 19.797333 44.373333 44.373333v258.048c0 24.576-19.797333 44.373333-44.373333 44.373334h-104.96c-20.48 0-40.618667 5.802667-58.026667 16.725333l-17.408 10.922667-17.408-10.922667a109.568 109.568 0 0 0-58.026666-16.725333H482.986667c-24.576 0-44.373333-19.797333-44.373334-44.373334V450.901333zM500.053333 468.309333v222.037334h87.893334c15.701333 0 31.061333 2.730667 45.738666 7.850666V489.813333a70.314667 70.314667 0 0 0-47.445333-21.504H500.053333z m193.024 21.504v208.384a139.776 139.776 0 0 1 45.738667-7.850666h87.893333V468.309333h-86.186666a70.314667 70.314667 0 0 0-47.445334 21.504z" fill="#FFFFFF" />
<path d="M531.114667 529.408h70.997333c9.557333 0 17.066667 7.509333 17.066667 17.066667s-7.509333 17.066667-17.066667 17.066666H531.114667c-9.557333 0-17.066667-7.509333-17.066667-17.066666s7.509333-17.066667 17.066667-17.066667zM531.114667 600.064h70.997333c9.557333 0 17.066667 7.509333 17.066667 17.066667s-7.509333 17.066667-17.066667 17.066666H531.114667c-9.557333 0-17.066667-7.509333-17.066667-17.066666s7.509333-17.066667 17.066667-17.066667zM724.650667 529.408h70.997333c9.557333 0 17.066667 7.509333 17.066667 17.066667s-7.509333 17.066667-17.066667 17.066666H724.650667c-9.557333 0-17.066667-7.509333-17.066667-17.066666s7.509333-17.066667 17.066667-17.066667zM724.650667 600.064h70.997333c9.557333 0 17.066667 7.509333 17.066667 17.066667s-7.509333 17.066667-17.066667 17.066666H724.650667c-9.557333 0-17.066667-7.509333-17.066667-17.066666s7.509333-17.066667 17.066667-17.066667z" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592099206" class="icon" viewBox="0 0 1228 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1769" xmlns:xlink="http://www.w3.org/1999/xlink" width="239.84375" height="200"><path d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728" fill="#155d70" p-id="1770"></path><path d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667" fill="#24839b" p-id="1771"></path><path d="M683.861333 493.021867a83.421867 83.421867 0 1 1-166.8096 0 83.421867 83.421867 0 0 1 166.8096 0" fill="#FFFFFF" p-id="1772"></path><path d="M443.733333 739.9424C443.733333 667.921067 513.911467 609.553067 600.439467 609.553067c86.562133 0 156.706133 58.368 156.706133 130.389333 0 0-9.216 48.981333-156.706133 48.981333S443.733333 739.9424 443.733333 739.9424" fill="#FFFFFF" p-id="1773"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592093649" class="icon" viewBox="0 0 1228 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1457" xmlns:xlink="http://www.w3.org/1999/xlink" width="239.84375" height="200"><path d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728" fill="#155d70" p-id="1458"></path><path d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667" fill="#24839b" p-id="1459"></path><path d="M698.368 724.992c0 9.557333-7.850667 17.066667-17.066667 17.066667-9.557333 0-17.066667-7.509333-17.066666-17.066667v-154.624c0-9.557333 7.509333-17.066667 17.066666-17.066667 9.216 0 17.066667 7.509333 17.066667 17.066667v154.624z m-111.957333 0c0 9.557333-7.509333 17.066667-17.066667 17.066667-9.216 0-17.066667-7.509333-17.066667-17.066667v-154.624c0-9.557333 7.850667-17.066667 17.066667-17.066667 9.557333 0 17.066667 7.509333 17.066667 17.066667v154.624z m158.378666-219.136h-238.933333c-18.773333 0-34.133333 15.36-34.133333 34.133333v215.381334c0 19.114667 15.36 34.133333 34.133333 34.133333h238.933333c18.773333 0 34.133333-15.018667 34.133334-34.133333v-215.381334c0-18.773333-15.36-34.133333-34.133334-34.133333z" fill="#FFFFFF" p-id="1460"></path><path d="M806.912 454.997333v1.365334c0 11.946667-9.898667 21.504-21.845333 21.504H465.578667c-11.946667 0-21.845333-9.557333-21.845334-21.504V454.997333c0-11.946667 9.898667-21.845333 21.845334-21.845333h117.76v-1.706667c0-12.288 9.898667-21.845333 21.845333-21.845333h40.277333c11.946667 0 21.845333 9.557333 21.845334 21.845333v1.706667H785.066667c11.946667 0 21.845333 9.898667 21.845333 21.845333" fill="#FFFFFF" p-id="1461"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592096004" class="icon" viewBox="0 0 1228 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1614" xmlns:xlink="http://www.w3.org/1999/xlink" width="239.84375" height="200"><path d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728" fill="#155d70" p-id="1615"></path><path d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667" fill="#24839b" p-id="1616"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592129543" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3500" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M952.888889 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H156.444444c-62.862222 0-113.777778-50.915556-113.777777-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777777-113.777778h514.844445L952.888889 281.6z" fill="#85BCFF" p-id="3501"></path><path d="M676.664889 167.822222V0l281.6 281.6h-167.822222c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#529EE0" p-id="3502"></path><path d="M685.824 363.804444a53.76 53.76 0 0 1 53.731556 53.731556v307.029333a53.76 53.76 0 0 1-53.731556 53.731556H309.76a53.731556 53.731556 0 0 1-53.731556-53.76V417.564444c0-29.667556 24.035556-53.731556 53.731556-53.731555H685.795556z m-72.903111 149.674667l-138.183111 146.545778-80.583111-62.805333-92.131556 94.208v31.402666c0 11.548444 10.325333 20.906667 23.04 20.906667h345.400889c12.714667 0 23.04-9.386667 23.04-20.906667v-125.610666l-80.583111-83.740445z m-227.896889-85.532444a32.085333 32.085333 0 1 0 0 64.142222 32.085333 32.085333 0 0 0 0-64.142222z" fill="#FFFFFF" p-id="3503"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H170.666667c-62.862222 0-113.777778-50.915556-113.777778-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#6C7AE0"></path><path d="M685.511111 167.822222V0L967.111111 281.6H799.288889c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#4F5FC9"></path><text x="505" y="705" fill="#FFFFFF" font-family="Arial, Helvetica, sans-serif" font-size="290" font-weight="700" text-anchor="middle">MD</text></svg>

After

Width:  |  Height:  |  Size: 863 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592116561" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2716" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.577778-51.2 113.777778-113.777778 113.777778H170.666667c-62.577778 0-113.777778-51.2-113.777778-113.777778V113.777778c0-62.577778 51.2-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#D23B41" p-id="2717"></path><path d="M685.511111 224.711111V0L967.111111 281.6H742.4c-31.288889 0-56.888889-25.6-56.888889-56.888889" fill="#9C171C" p-id="2718"></path><path d="M680.277333 662.698667c-11.889778-1.194667-23.751111-3.640889-35.640889-9.728 10.666667-2.133333 20.110222-2.133333 30.776889-2.133334 23.751111 0 28.330667 5.774222 28.330667 9.443556-6.997333 2.417778-15.246222 3.356444-23.466667 2.417778z m-120.945777-15.530667c-25.884444 5.802667-54.556444 14.336-80.440889 23.779556v-2.446223l-2.446223 1.223111c13.084444-26.197333 25.002667-53.333333 35.640889-80.753777l0.938667 1.223111 1.194667-2.133334c13.112889 20.110222 29.866667 40.220444 47.530666 57.884445h-3.640889l1.223112 1.223111zM497.777778 417.450667c1.223111-1.223111 3.669333-1.223111 4.551111-1.223111h3.697778a96.739556 96.739556 0 0 1-1.251556 61.553777c-8.220444-18.915556-11.861333-40.220444-6.997333-60.330666zM352.142222 770.275556l-3.669333 1.223111a96.768 96.768 0 0 1 42.666667-34.417778c-9.443556 15.502222-22.556444 27.392-38.997334 33.194667z m324.494222-155.107556c-25.002667 0-49.664 3.669333-74.666666 8.248889a353.365333 353.365333 0 0 1-73.415111-94.776889c20.110222-66.417778 21.333333-111.217778 5.774222-132.551111a39.253333 39.253333 0 0 0-30.748445-15.502222c-15.246222-1.223111-29.582222 6.087111-36.579555 18.887111-21.333333 35.640889 9.443556 105.415111 23.779555 134.058666-16.782222 50.887111-36.864 99.328-63.089777 146.858667-112.412444 48.440889-114.858667 77.994667-114.858667 88.661333 0 13.084444 7.310222 26.197333 20.110222 32 4.864 3.640889 11.889778 4.835556 17.976889 4.835556 29.582222 0 64-33.194667 100.551111-98.389333 46.307556-18.887111 92.615111-34.133333 141.084445-44.8a153.941333 153.941333 0 0 0 87.722666 35.356444c20.110222 0 59.107556 0 59.107556-40.220444 1.223111-15.530667-6.997333-41.443556-62.748445-42.666667z" fill="#FFFFFF" p-id="2719"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592113677" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2557" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.577778-51.2 113.777778-113.777778 113.777778H170.666667c-62.577778 0-113.777778-51.2-113.777778-113.777778V113.777778c0-62.577778 51.2-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#F16C41" p-id="2558"></path><path d="M685.511111 224.711111V0L967.111111 281.6H742.4c-31.288889 0-56.888889-25.6-56.888889-56.888889" fill="#CD4B29" p-id="2559"></path><path d="M525.880889 648.135111a88.32 88.32 0 0 1-68.750222-32.995555 87.04 87.04 0 0 1-19.626667-55.381334c0-21.048889 7.253333-40.248889 19.626667-55.381333a88.234667 88.234667 0 0 1 68.750222-32.995556 88.490667 88.490667 0 0 1 88.376889 88.376889 88.519111 88.519111 0 0 1-88.376889 88.376889m0-235.690667c-24.945778 0-48.327111 6.087111-68.750222 17.294223a143.075556 143.075556 0 0 0-58.88 56.945777v146.119112a143.132444 143.132444 0 0 0 58.88 56.974222c20.423111 11.178667 43.804444 17.265778 68.750222 17.265778a147.342222 147.342222 0 0 0 147.285333-147.285334 147.342222 147.342222 0 0 0-147.285333-147.342222" fill="#FFFFFF" p-id="2560"></path><path d="M398.222222 824.888889h58.908445V412.444444H398.222222z" fill="#FFFFFF" p-id="2561"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592134021" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3813" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.577778-51.2 113.777778-113.777778 113.777778H170.666667c-62.577778 0-113.777778-51.2-113.777778-113.777778V113.777778c0-62.577778 51.2-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#31A8FF" p-id="3814"></path><path d="M685.511111 224.711111V0L967.111111 281.6H742.4c-31.288889 0-56.888889-25.6-56.888889-56.888889" fill="#0689E9" p-id="3815"></path><path d="M428.999111 557.852444h17.436445c13.568 0 23.495111-2.616889 29.752888-7.310222 6.257778-4.977778 9.415111-13.084444 9.415112-24.291555 0-11.235556-3.413333-19.057778-9.671112-23.751111-6.257778-4.949333-16.184889-7.310222-29.496888-7.310223h-17.436445v62.663111z m19.768889-88.775111c44.942222 0 67.669333 19.057778 67.669333 57.173334 0 38.371556-22.755556 57.685333-68.152889 57.685333h-19.285333v71.537778h-30.549333V469.048889h50.346666zM621.880889 559.672889h-23.950222c-1.735111-6.826667-4.323556-11.747556-7.765334-14.364445-3.697778-2.872889-9.272889-4.152889-16.839111-4.152888-6.485333 0-11.434667 1.024-14.677333 3.356444-3.868444 2.360889-5.603556 6.030222-5.603556 11.235556 0 4.181333 2.816 8.106667 8.817778 11.235555 3.697778 1.820444 11.036444 4.437333 22.243556 7.822222 12.515556 3.669333 22.016 7.850667 28.074666 12.8 8.817778 6.798222 13.368889 16.469333 13.368889 28.444445 0 28.728889-16.810667 43.093333-50.289778 43.093333-31.061333 0-47.672889-15.928889-50.062222-47.530667h23.950222c1.308444 8.618667 3.896889 14.620444 7.566223 18.289778 3.669333 3.128889 9.500444 4.949333 17.720889 4.949334 17.237333 0 25.884444-5.745778 25.884444-16.696889 0-6.286222-3.242667-11.235556-9.728-14.876445-3.441778-2.104889-10.808889-4.465778-22.016-7.566222-13.141333-3.697778-22.471111-7.594667-27.818667-11.747556-8.647111-6.542222-12.942222-15.672889-12.942222-27.420444 0-12.544 4.067556-22.186667 12.714667-29.013333 8.448-7.281778 19.427556-10.666667 33.251555-10.666667 29.297778 0 45.511111 14.108444 48.071111 42.808889" fill="#FFFFFF" p-id="3816"></path><path d="M685.056 341.333333H338.915556a56.888889 56.888889 0 0 0-56.888889 56.888889v346.140445a56.888889 56.888889 0 0 0 56.888889 56.888889h346.140444a56.888889 56.888889 0 0 0 56.888889-56.888889V398.222222a56.888889 56.888889 0 0 0-56.888889-56.888889zM338.915556 369.777778h346.140444a28.444444 28.444444 0 0 1 28.444444 28.444444v346.140445a28.444444 28.444444 0 0 1-28.444444 28.444444H338.915556a28.444444 28.444444 0 0 1-28.444445-28.444444V398.222222a28.444444 28.444444 0 0 1 28.444445-28.444444z" fill="#FFFFFF" p-id="3817"></path></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H170.666667c-62.862222 0-113.777778-50.915556-113.777778-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#4B8BBE"></path><path d="M685.511111 167.822222V0L967.111111 281.6H799.288889c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#37648E"></path><text x="512" y="705" fill="#FFFFFF" font-family="Arial, Helvetica, sans-serif" font-size="300" font-weight="700" text-anchor="middle">PY</text></svg>

After

Width:  |  Height:  |  Size: 863 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592110976" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2399" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.577778-51.2 113.777778-113.777778 113.777778H170.666667c-62.577778 0-113.777778-51.2-113.777778-113.777778V113.777778c0-62.577778 51.2-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#62C558" p-id="2400"></path><path d="M685.511111 224.711111V0L967.111111 281.6H742.4c-31.288889 0-56.888889-25.6-56.888889-56.888889" fill="#2A8121" p-id="2401"></path><path d="M682.666667 724.024889L638.691556 768 341.333333 470.670222 385.308444 426.666667zM454.087111 611.128889l44.088889 44.088889L385.422222 768 341.333333 723.911111zM682.666667 470.755556l-113.066667 113.066666-44.088889-44.088889L638.577778 426.666667z" fill="#FFFFFF" p-id="2402"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592119224" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2874" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H170.666667c-62.862222 0-113.777778-50.915556-113.777778-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#6D9FE5" p-id="2875"></path><path d="M685.511111 167.822222V0L967.111111 281.6H799.288889c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#4B80CB" p-id="2876"></path><path d="M344.177778 485.575111h312.888889V426.666667h-312.888889zM471.153778 770.019556h58.908444v-284.444445h-58.908444z" fill="#FFFFFF" p-id="2877"></path></svg>

After

Width:  |  Height:  |  Size: 931 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592131838" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3656" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H170.666667c-62.862222 0-113.777778-50.915556-113.777778-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#C386F0" p-id="3657"></path><path d="M284.444444 398.222222m42.666667 0l298.666667 0q42.666667 0 42.666666 42.666667l0 234.666667q0 42.666667-42.666666 42.666666l-298.666667 0q-42.666667 0-42.666667-42.666666l0-234.666667q0-42.666667 42.666667-42.666667Z" fill="#FFFFFF" p-id="3658"></path><path d="M738.417778 457.841778a31.971556 31.971556 0 0 1 48.014222 27.676444v154.538667c0 24.632889-26.652444 40.021333-47.985778 27.704889L684.430222 636.586667V488.96z" fill="#FFFFFF" p-id="3659"></path><path d="M685.511111 167.822222V0L967.111111 281.6H799.288889c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#A15FDE" p-id="3660"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592126977" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3348" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M952.888889 281.6V910.222222c0 62.862222-50.915556 113.777778-113.777778 113.777778H156.444444c-62.862222 0-113.777778-50.915556-113.777777-113.777778V113.777778c0-62.862222 50.915556-113.777778 113.777777-113.777778h514.844445L952.888889 281.6z" fill="#58C5C2" p-id="3349"></path><path d="M511.146667 294.656l0.711111 0.142222a246.044444 246.044444 0 0 1 0 491.292445 17.92 17.92 0 0 1-5.688889 0.284444h-0.995556l-7.395555 0.113778a246.044444 246.044444 0 1 1 8.391111-491.946667 17.237333 17.237333 0 0 1 4.977778 0.113778z m-98.986667 355.271111l-92.501333 0.028445a209.180444 209.180444 0 0 0 144.896 96.938666 334.961778 334.961778 0 0 1-52.394667-96.938666z m263.736889 0.028445H592.497778a337.92 337.92 0 0 1-50.062222 94.776888 209.351111 209.351111 0 0 0 133.461333-94.776888z m-122.737778 0h-101.603555a298.069333 298.069333 0 0 0 51.2 85.248c21.902222-26.026667 38.684444-54.897778 50.403555-85.248z m-155.534222-159.288889h-102.968889A209.578667 209.578667 0 0 0 288.711111 540.444444c0 25.486222 4.551111 49.92 12.913778 72.533334h100.181333a338.773333 338.773333 0 0 1-4.181333-122.311111z m171.832889 0.028444h-134.4a301.511111 301.511111 0 0 0 4.721778 122.282667h125.013333c9.813333-40.078222 11.377778-81.720889 4.664889-122.282667z m131.413333 0h-93.980444a342.186667 342.186667 0 0 1-4.152889 122.282667h91.192889A208.64 208.64 0 0 0 706.844444 540.444444c0-17.152-2.076444-33.820444-5.973333-49.777777z m-236.288-156.728889l-0.711111 0.142222A209.351111 209.351111 0 0 0 307.484444 453.688889h97.735112a334.705778 334.705778 0 0 1 59.335111-119.694222z m38.200889 11.719111l-3.868444 4.579556a298.012444 298.012444 0 0 0-55.239112 103.452444h117.248a301.397333 301.397333 0 0 0-58.140444-108.032z m39.651556-9.528889l4.295111 5.831112a338.062222 338.062222 0 0 1 52.622222 111.701333h88.689778a209.464889 209.464889 0 0 0-145.607111-117.532445z" fill="#FFFFFF" p-id="3350"></path><path d="M676.664889 167.822222V0l281.6 281.6h-167.822222c-62.862222 0-113.777778-50.915556-113.777778-113.777778" fill="#2B9592" p-id="3351"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780592104510" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2083" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M967.111111 281.6V910.222222c0 62.577778-51.2 113.777778-113.777778 113.777778H170.666667c-62.577778 0-113.777778-51.2-113.777778-113.777778V113.777778c0-62.577778 51.2-113.777778 113.777778-113.777778h514.844444L967.111111 281.6z" fill="#4F6BF6" p-id="2084"></path><path d="M581.262222 755.626667h59.363556L739.555556 439.04h-59.335112z" fill="#FFFFFF" p-id="2085"></path><path d="M685.511111 224.711111V0L967.111111 281.6H742.4c-31.288889 0-56.888889-25.6-56.888889-56.888889" fill="#243EBB" p-id="2086"></path><path d="M640.625778 755.626667h-59.363556l-98.929778-277.020445h59.335112zM442.737778 755.626667h-59.363556L284.444444 439.04h59.335112z" fill="#FFFFFF" p-id="2087"></path><path d="M383.374222 755.626667h59.363556l98.929778-277.020445h-59.335112z" fill="#FFFFFF" p-id="2088"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1743006412437" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1457" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M141.074286 906.496h741.851428c89.581714 0 134.582857-44.562286 134.582857-132.845714V250.331429c0-88.283429-45.001143-132.845714-134.582857-132.845715H141.074286C51.931429 117.504 6.491429 161.645714 6.491429 250.331429V773.668571c0 88.704 45.44 132.845714 134.582857 132.845715z m1.28-68.992c-42.861714 0-66.852571-22.710857-66.852572-67.291429V253.805714c0-44.580571 23.990857-67.291429 66.852572-67.291428h190.72v651.008z m739.291428-651.008c42.422857 0 66.852571 22.710857 66.852572 67.291429V770.194286c0 44.580571-24.429714 67.291429-66.852572 67.291428h-481.28V186.496z m-630.857143 159.012571c12.854857 0 24.429714-11.574857 24.429715-24.009142 0-12.854857-11.574857-23.990857-24.429715-23.990858h-92.16c-12.836571 0-23.990857 11.136-23.990857 23.990858 0 12.434286 11.154286 24.009143 24.009143 24.009142z m0 110.994286c12.854857 0 24.429714-11.574857 24.429715-24.429714 0-12.854857-11.574857-23.588571-24.429715-23.588572h-92.16c-12.836571 0-23.990857 10.733714-23.990857 23.588572 0 12.854857 11.154286 24.429714 24.009143 24.429714z m0 110.573714c12.854857 0 24.429714-10.715429 24.429715-23.588571 0-12.836571-11.574857-23.990857-24.429715-23.990857h-92.16c-12.836571 0-23.990857 11.154286-23.990857 24.009143s11.154286 23.570286 24.009143 23.570285z" p-id="1458" fill="#2c2c2c"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1743006443448" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1794" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M141.074286 906.496h741.851428c89.581714 0 134.582857-44.562286 134.582857-132.845714V250.331429c0-88.283429-45.001143-132.845714-134.582857-132.845715H141.074286C51.931429 117.504 6.491429 161.645714 6.491429 250.331429V773.668571c0 88.704 45.44 132.845714 134.582857 132.845715z m1.28-68.992c-42.861714 0-66.852571-22.710857-66.852572-67.291429V253.805714c0-44.580571 23.990857-67.291429 66.852572-67.291428h482.139428v651.008z m739.291428-651.008c42.422857 0 66.852571 22.710857 66.852572 67.291429V770.194286c0 44.580571-24.429714 67.291429-66.852572 67.291428H691.785143V186.496z m-107.574857 159.012571h91.721143c13.275429 0 24.429714-11.574857 24.429714-24.009142 0-12.854857-11.154286-23.990857-24.429714-23.990858h-91.721143c-12.854857 0-24.429714 11.136-24.429714 23.990858 0 12.434286 11.574857 24.009143 24.429714 24.009142z m0 110.994286h91.721143c13.275429 0 24.429714-11.574857 24.429714-24.429714 0-12.854857-11.154286-23.588571-24.429714-23.588572h-91.721143c-12.854857 0-24.429714 10.733714-24.429714 23.588572 0 12.854857 11.574857 24.429714 24.429714 24.429714z m0 110.573714h91.721143c13.275429 0 24.429714-10.715429 24.429714-23.588571 0-12.836571-11.154286-23.990857-24.429714-23.990857h-91.721143c-12.854857 0-24.429714 11.154286-24.429714 24.009143s11.574857 23.570286 24.429714 23.570285z" p-id="1795"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,470 @@
<template>
<div class="account-settings">
<div class="header-section">
<div class="header-content">
<div class="section-title">账户设置</div>
<p class="section-description">管理当前账户资料身份信息和 API Key</p>
</div>
<a-button class="lucide-icon-btn" :loading="refreshing" @click="refreshProfile">
<template #icon><RefreshCw :size="16" :class="{ spin: refreshing }" /></template>
刷新
</a-button>
</div>
<div class="account-card profile-card">
<div class="profile-left">
<a-upload
:show-upload-list="false"
:before-upload="beforeUpload"
@change="handleAvatarChange"
accept="image/*"
>
<div class="avatar-upload" :class="{ uploading: avatarUploading }">
<FallbackAvatar
:src="userStore.avatar"
:default-src="avatarDefaultSrc"
:name="userStore.username"
:seed="userStore.uid || userStore.username"
kind="user"
:size="80"
shape="circle"
:alt="userStore.username"
class="account-avatar"
/>
<div class="avatar-mask">
<Upload v-if="!avatarUploading" :size="16" />
<RefreshCw v-else :size="16" class="spin" />
<span>{{ userStore.avatar ? '更换' : '上传' }}</span>
</div>
</div>
</a-upload>
<div class="profile-fields">
<div class="profile-row editable-row">
<span class="profile-label">用户名</span>
<a-input
v-if="editingField === 'username'"
ref="usernameInput"
v-model:value="profileDraft.username"
class="inline-input"
size="small"
:max-length="20"
:disabled="savingField === 'username'"
@press-enter="saveField('username')"
@keydown.esc.stop.prevent="cancelField"
@blur="cancelField"
/>
<button v-else type="button" class="editable-value" @click="startFieldEdit('username')">
{{ userStore.username || '未设置' }}
</button>
</div>
<div class="profile-row editable-row">
<span class="profile-label">手机号</span>
<a-input
v-if="editingField === 'phone_number'"
ref="phoneInput"
v-model:value="profileDraft.phone_number"
class="inline-input"
size="small"
:max-length="11"
:disabled="savingField === 'phone_number'"
@press-enter="saveField('phone_number')"
@keydown.esc.stop.prevent="cancelField"
@blur="cancelField"
/>
<button
v-else
type="button"
class="editable-value"
@click="startFieldEdit('phone_number')"
>
{{ userStore.phoneNumber || '未设置' }}
</button>
</div>
<div class="profile-row">
<span class="profile-label">UID</span>
<span class="profile-value mono">{{ userStore.uid || '未设置' }}</span>
</div>
</div>
</div>
<div class="identity-panel">
<div class="identity-item">
<span class="identity-icon"><ShieldCheck :size="15" /></span>
<span class="profile-label">权限</span>
<span class="profile-value" :style="{ color: getRoleColor(userStore.userRole) }">
{{ userRoleText }}
</span>
</div>
<div class="identity-item">
<span class="identity-icon"><Building2 :size="15" /></span>
<span class="profile-label">部门</span>
<span class="profile-value">{{ userStore.departmentName || '默认部门' }}</span>
</div>
</div>
</div>
<div class="account-card apikey-card">
<ApiKeyManagementComponent />
</div>
</div>
</template>
<script setup>
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { Building2, RefreshCw, ShieldCheck, Upload } from 'lucide-vue-next'
import ApiKeyManagementComponent from '@/components/ApiKeyManagementComponent.vue'
import FallbackAvatar from '@/components/common/FallbackAvatar.vue'
import { useUserStore } from '@/stores/user'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
const userStore = useUserStore()
const avatarUploading = ref(false)
const refreshing = ref(false)
const savingField = ref('')
const editingField = ref('')
const usernameInput = ref(null)
const phoneInput = ref(null)
const profileDraft = reactive({
username: '',
phone_number: ''
})
const avatarDefaultSrc = computed(() => (userStore.uid ? generatePixelAvatar(userStore.uid) : ''))
const userRoleText = computed(() => {
switch (userStore.userRole) {
case 'superadmin':
return '超级管理员'
case 'admin':
return '管理员'
case 'user':
return '普通用户'
default:
return '未知角色'
}
})
const syncProfileDraft = () => {
profileDraft.username = userStore.username || ''
profileDraft.phone_number = userStore.phoneNumber || ''
}
const refreshProfile = async () => {
refreshing.value = true
try {
await userStore.getCurrentUser()
syncProfileDraft()
message.success('账户信息已刷新')
} catch (error) {
console.error('刷新用户信息失败:', error)
message.error('刷新失败:' + (error.message || '请稍后重试'))
} finally {
refreshing.value = false
}
}
const startFieldEdit = async (field) => {
syncProfileDraft()
editingField.value = field
await nextTick()
const inputRef = field === 'username' ? usernameInput.value : phoneInput.value
inputRef?.focus?.()
}
const cancelField = () => {
if (savingField.value) return
editingField.value = ''
syncProfileDraft()
}
const saveField = async (field) => {
const payload = {}
if (field === 'username') {
const username = profileDraft.username.trim()
if (username.length < 2 || username.length > 20) {
message.error('用户名长度必须在 2-20 个字符之间')
return
}
if (username === userStore.username) {
cancelField()
return
}
payload.username = username
}
if (field === 'phone_number') {
const phoneNumber = profileDraft.phone_number.trim()
if (phoneNumber && !validatePhoneNumber(phoneNumber)) {
message.error('请输入正确的手机号格式')
return
}
if (phoneNumber === (userStore.phoneNumber || '')) {
cancelField()
return
}
payload.phone_number = phoneNumber
}
savingField.value = field
try {
await userStore.updateProfile(payload)
syncProfileDraft()
editingField.value = ''
message.success('个人资料更新成功')
} catch (error) {
console.error('更新个人资料失败:', error)
message.error('更新失败:' + (error.message || '请稍后重试'))
} finally {
savingField.value = ''
}
}
const getRoleColor = (role) => {
switch (role) {
case 'superadmin':
return 'var(--color-error-700)'
case 'admin':
return 'var(--color-primary-500)'
case 'user':
return 'var(--color-success-500)'
default:
return 'var(--gray-600)'
}
}
const validatePhoneNumber = (phone) => {
if (!phone) return true
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error('只能上传图片文件!')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
message.error('图片大小不能超过 5MB')
return false
}
return true
}
const handleAvatarChange = async (info) => {
if (info.file.status === 'uploading') {
avatarUploading.value = true
return
}
if (info.file.status === 'done') {
avatarUploading.value = false
return
}
try {
avatarUploading.value = true
await userStore.uploadAvatar(info.file.originFileObj || info.file)
message.success('头像上传成功!')
} catch (error) {
console.error('头像上传失败:', error)
message.error('头像上传失败:' + (error.message || '请稍后重试'))
} finally {
avatarUploading.value = false
}
}
watch(() => [userStore.username, userStore.phoneNumber], syncProfileDraft, { immediate: true })
</script>
<style lang="less" scoped>
.account-settings {
display: flex;
flex-direction: column;
gap: 16px;
.account-card {
padding: 18px;
border-radius: 12px;
background: var(--gray-0);
border: 1px solid var(--gray-150);
}
.profile-card {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 20px;
background: var(--gray-25);
@media (max-width: 760px) {
flex-direction: column;
}
}
.profile-left {
min-width: 0;
display: flex;
align-items: center;
gap: 18px;
flex: 1;
@media (max-width: 520px) {
align-items: flex-start;
flex-direction: column;
}
}
.avatar-upload {
width: 80px;
height: 80px;
position: relative;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
flex: 0 0 auto;
.account-avatar {
width: 80px;
height: 80px;
border: 3px solid var(--gray-0);
}
&:hover .avatar-mask,
&.uploading .avatar-mask {
opacity: 1;
}
}
.avatar-mask {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--gray-0);
font-size: 12px;
background: rgba(0, 0, 0, 0.48);
opacity: 0;
transition: opacity 0.2s ease;
}
.profile-fields {
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.profile-row {
min-width: 0;
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.profile-label {
color: var(--gray-600);
font-size: 13px;
flex-shrink: 0;
}
.profile-value,
.editable-value {
min-width: 0;
color: var(--gray-900);
font-size: 14px;
font-weight: 500;
line-height: 24px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.editable-value {
width: fit-content;
max-width: 100%;
padding: 0 6px;
margin-left: -6px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
&:hover {
color: var(--main-color);
background: var(--main-5);
}
}
.inline-input {
width: min(260px, 100%);
}
.identity-panel {
min-width: 220px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
padding: 14px;
border-radius: 10px;
background: var(--gray-0);
@media (max-width: 760px) {
min-width: 0;
}
}
.identity-item {
min-width: 0;
display: grid;
grid-template-columns: 20px 42px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.identity-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
background: var(--gray-50);
color: var(--gray-500);
}
.mono {
font-family: 'Monaco', 'Consolas', monospace;
}
.apikey-card {
padding: 16px;
}
}
:deep(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<template>
<section v-if="normalizedArtifacts.length" class="artifacts-list">
<div v-for="file in normalizedArtifacts" :key="file.path" class="artifact-card">
<button
type="button"
class="item-main"
:title="`打开 ${file.name}`"
@click="openPreview(file)"
>
<FileTypeIcon :name="file.path" :size="20" class="item-icon" />
<div class="item-meta">
<div class="item-name">{{ file.name }}</div>
<div class="item-desc">{{ getFileMetaLabel(file.path) }}</div>
</div>
</button>
<div class="item-actions">
<button class="item-action-btn" title="下载" @click.stop="downloadFile(file)">
<Download :size="15" />
</button>
<button
class="item-action-btn"
:title="isSaving(file.path) ? '保存中' : '保存到工作区'"
:disabled="isSaving(file.path)"
@click.stop="saveToWorkspace(file)"
>
<LoaderCircle v-if="isSaving(file.path)" :size="15" class="item-action-spin" />
<Save v-else :size="15" />
</button>
</div>
</div>
</section>
</template>
<script setup>
import { computed, ref } from 'vue'
import { message } from 'ant-design-vue'
import { Download, LoaderCircle, Save } from 'lucide-vue-next'
import { threadApi } from '@/apis/agent_api'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
import { downloadViewerFile } from '@/apis/viewer_filesystem'
const props = defineProps({
artifacts: {
type: Array,
default: () => []
},
threadId: {
type: String,
default: null
}
})
const emit = defineEmits(['saved', 'open-preview'])
const normalizedArtifacts = computed(() =>
(props.artifacts || [])
.filter((path) => typeof path === 'string' && path.trim())
.map((path) => {
const normalizedPath = path.trim()
return {
path: normalizedPath,
name: normalizedPath.split('/').pop() || normalizedPath
}
})
)
const savingState = ref({})
const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match && utf8Match[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (error) {
console.warn('解析 UTF-8 文件名失败:', error)
}
}
const asciiMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
return asciiMatch?.[1] || ''
}
const getFileMetaLabel = (path) => {
const filename =
String(path || '')
.split('/')
.pop() || ''
if (!filename.includes('.')) return '交付文件'
const extension = filename.split('.').pop()
return extension ? `交付文件 · ${extension.toUpperCase()}` : '交付文件'
}
const openPreview = (file) => {
emit('open-preview', file)
}
const downloadFile = async (file) => {
if (!props.threadId || !file?.path) return
try {
const response = await downloadViewerFile(props.threadId, file.path)
const blob = await response.blob()
const contentDisposition =
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')
const filename = parseDownloadFilename(contentDisposition) || file.name
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
message.error(error?.message || '下载文件失败')
}
}
const isSaving = (path) => !!savingState.value[path]
const setSaving = (path, saving) => {
savingState.value = {
...savingState.value,
[path]: saving
}
}
const saveToWorkspace = async (file) => {
if (!props.threadId || !file?.path || isSaving(file.path)) return
setSaving(file.path, true)
try {
const result = await threadApi.saveThreadArtifactToWorkspace(props.threadId, file.path)
message.success(`已保存到工作区:${result.saved_path}`)
emit('saved', result)
} catch (error) {
message.error(error?.message || '保存到工作区失败')
} finally {
setSaving(file.path, false)
}
}
</script>
<style scoped lang="less">
.artifacts-list {
width: 100%;
margin: 8px 0 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.artifact-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
border: 1px solid var(--gray-150);
border-radius: 12px;
background: linear-gradient(180deg, var(--gray-25) 0%, var(--gray-0) 100%);
transition:
background 0.18s ease,
border-color 0.18s ease;
&:hover {
border-color: var(--main-200);
background: var(--gray-0);
}
}
.item-main {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 10px;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
padding: 10px 8px 10px 12px;
}
.item-icon {
flex-shrink: 0;
font-size: 18px;
opacity: 0.86;
}
.item-meta {
min-width: 0;
flex: 1;
}
.item-name {
font-size: 13px;
font-weight: 600;
color: var(--gray-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
.item-desc {
margin-top: 2px;
font-size: 12px;
color: var(--gray-500);
line-height: 1.2;
}
.item-actions {
display: flex;
align-items: center;
gap: 2px;
margin-right: 8px;
}
.item-action-btn {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--gray-600);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.item-action-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.item-action-btn:hover:not(:disabled) {
color: var(--main-700);
background: var(--gray-100);
}
.item-action-spin {
animation: artifacts-spin 1s linear infinite;
}
@keyframes artifacts-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.artifacts-list {
margin-top: 6px;
}
.artifact-card {
align-items: stretch;
}
.item-main {
padding: 9px 6px 9px 12px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
<template>
<div class="agent-env-settings">
<div class="header-section">
<div class="header-content">
<div class="section-title">沙盒环境变量</div>
<p class="section-description">
配置当前用户的 Agent 沙盒环境变量新建沙盒时会注入这些变量并覆盖同名全局 sandbox.env
</p>
</div>
<div class="header-actions">
<a-button class="lucide-icon-btn" :loading="loading" @click="loadAgentEnv">
<template #icon><RefreshCw :size="16" :class="{ spin: loading }" /></template>
刷新
</a-button>
<a-button type="primary" :loading="saving" @click="saveAgentEnv">
{{ saveButtonText }}
</a-button>
</div>
</div>
<div class="env-tip">保存后仅对新建沙盒生效已运行沙盒不会热更新</div>
<a-spin :spinning="loading">
<McpEnvEditor
:key="editorRevision"
:modelValue="draftEnv"
:locked-keys="savedEnvKeys"
conceal-locked-values
@update:modelValue="updateDraftEnv"
/>
</a-spin>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { RefreshCw } from 'lucide-vue-next'
import { agentEnvApi } from '@/apis/agent_env_api'
import McpEnvEditor from '@/components/McpEnvEditor.vue'
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
const MAX_ENV_COUNT = 200
const MAX_ENV_KEY_LENGTH = 128
const MAX_ENV_VALUE_LENGTH = 32768
const loading = ref(false)
const saving = ref(false)
const draftEnv = ref({})
const lastSavedEnv = ref({})
const editorRevision = ref(0)
const normalizeEnv = (env) => {
if (!env || typeof env !== 'object' || Array.isArray(env)) {
return {}
}
return Object.fromEntries(
Object.entries(env)
.map(([key, value]) => [key.trim(), value == null ? '' : String(value)])
.filter(([key]) => key)
)
}
const isSameEnv = (left, right) => {
const leftEntries = Object.entries(left)
const rightEntries = Object.entries(right)
if (leftEntries.length !== rightEntries.length) return false
return leftEntries.every(([key, value]) => right[key] === value)
}
const savedEnvKeys = computed(() => Object.keys(lastSavedEnv.value || {}))
const hasUnsavedChanges = computed(
() => !isSameEnv(normalizeEnv(draftEnv.value), lastSavedEnv.value)
)
const saveButtonText = computed(() => (hasUnsavedChanges.value ? '保存(有修改)' : '保存'))
const updateDraftEnv = (value) => {
const nextEnv = normalizeEnv(value)
if (!isSameEnv(draftEnv.value, nextEnv)) {
draftEnv.value = nextEnv
}
}
const validateEnv = (env) => {
const entries = Object.entries(env)
if (entries.length > MAX_ENV_COUNT) {
message.error(`环境变量数量不能超过 ${MAX_ENV_COUNT}`)
return false
}
for (const [key, value] of entries) {
if (key.length > MAX_ENV_KEY_LENGTH) {
message.error(`环境变量名长度不能超过 ${MAX_ENV_KEY_LENGTH}`)
return false
}
if (!ENV_KEY_PATTERN.test(key)) {
message.error(`环境变量名 ${key} 格式不正确`)
return false
}
if (value.length > MAX_ENV_VALUE_LENGTH) {
message.error(`环境变量 ${key} 的值过长`)
return false
}
}
return true
}
const loadAgentEnv = async () => {
loading.value = true
try {
const res = await agentEnvApi.get()
const env = normalizeEnv(res.env)
draftEnv.value = env
lastSavedEnv.value = env
editorRevision.value += 1
} catch (error) {
message.error(error.message || '加载环境变量失败')
} finally {
loading.value = false
}
}
const saveAgentEnv = async () => {
const env = normalizeEnv(draftEnv.value)
if (!validateEnv(env)) return
if (isSameEnv(env, lastSavedEnv.value)) {
message.info('环境变量未变化')
return
}
saving.value = true
try {
await agentEnvApi.update(env)
draftEnv.value = env
lastSavedEnv.value = env
editorRevision.value += 1
message.success('环境变量已保存')
} catch (error) {
message.error(error.message || '保存环境变量失败')
} finally {
saving.value = false
}
}
onMounted(loadAgentEnv)
</script>
<style lang="less" scoped>
.agent-env-settings {
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 12px;
@media (max-width: 760px) {
align-items: stretch;
flex-direction: column;
}
}
.header-content {
flex: 1;
min-width: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.env-tip {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 10px;
background: var(--main-10);
border: 1px solid var(--main-300);
color: var(--main-700);
font-size: 13px;
line-height: 1.5;
}
}
:deep(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
+945
View File
@@ -0,0 +1,945 @@
<template>
<div
class="agent-file-preview"
:class="[
containerClass,
{
'is-full-height': fullHeight,
'is-borderless': borderless,
'has-preview-header': showHeader
}
]"
>
<div v-if="showHeader" class="preview-header">
<div class="file-title">
<FileTypeIcon v-if="showFileIcon" :name="filePath" :size="18" />
<span class="file-path-title">{{ filePath }}</span>
</div>
<div class="modal-actions">
<button
v-if="canEdit && editMode !== 'edit'"
class="modal-action-btn"
@click="editMode = 'edit'"
title="编辑"
aria-label="编辑"
>
<FilePen :size="18" />
</button>
<div v-if="isHtmlFile" class="preview-mode-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button
v-if="showDownload && file"
class="modal-action-btn"
@click="$emit('download', file)"
title="下载"
>
<Download :size="18" />
</button>
<button
v-if="showFullscreen && file"
class="modal-action-btn"
@click="openFullscreenPreview"
title="全屏预览"
>
<Maximize :size="18" />
</button>
<button
v-if="showClose"
class="modal-action-btn"
@click="$emit('close')"
:title="closeTitle"
:aria-label="closeTitle"
>
<component :is="closeIconComponent" :size="18" />
</button>
</div>
</div>
<div
v-if="showInlineHtmlControls && !showHeader && isHtmlFile"
class="preview-mode-switch inline-html-preview-switch"
>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<div v-if="canEdit && editMode === 'edit'" class="edit-floating-actions">
<span v-if="draftChanged" class="edit-status-badge">未保存</span>
<button
v-if="draftChanged"
class="edit-floating-btn edit-floating-btn-primary"
:disabled="saving"
@click="requestSave"
:title="saving ? '保存中' : '保存'"
:aria-label="saving ? '保存中' : '保存'"
>
<Save :size="14" />
</button>
<button
class="edit-floating-btn"
:class="{ 'edit-floating-btn-danger': draftChanged }"
:disabled="saving"
@click="cancelEdit"
title="取消"
aria-label="取消"
>
<X :size="14" />
</button>
</div>
<div
class="file-content"
:class="[
contentClass,
{
'is-editing': canEdit && editMode === 'edit',
'is-iframe-preview':
file?.previewType === 'pdf' || (isHtmlFile && htmlPreviewMode === 'render')
}
]"
>
<template v-if="canEdit && editMode === 'edit'">
<textarea
v-model="draftContent"
class="file-edit-textarea"
:disabled="saving"
spellcheck="false"
/>
</template>
<template v-else-if="file?.previewType === 'image' && file?.previewUrl">
<div class="image-preview-wrapper">
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
</div>
</template>
<template v-else-if="file?.previewType === 'pdf' && file?.previewUrl">
<iframe :src="file.previewUrl" class="pdf-preview" :title="filePath" />
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
:key="`embedded-${htmlPreviewRenderKey}`"
class="html-preview"
:srcdoc="htmlPreviewSrcdoc"
:title="filePath"
sandbox="allow-scripts"
/>
</template>
<template v-else-if="isMarkdown">
<MarkdownPreview :content="formatContent(file?.content)" />
</template>
<template v-else-if="file?.supported === false">
<div class="unsupported-preview">
{{ file?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre v-if="Array.isArray(file?.content)" class="file-content-pre">{{
formatContent(file.content)
}}</pre>
<pre
v-else-if="isCodePreview && typeof file?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof file?.content === 'string'" class="file-content-pre">{{
file.content
}}</pre>
<pre v-else>{{ JSON.stringify(file, null, 2) }}</pre>
</template>
</div>
<Teleport to="body">
<div v-if="fullscreenPreviewVisible && file" class="fullscreen-preview-overlay">
<div class="fullscreen-preview-actions">
<div v-if="isHtmlFile" class="preview-mode-switch fullscreen-preview-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button
v-if="showDownload && file"
class="modal-action-btn fullscreen-action-btn"
@click="$emit('download', file)"
title="下载"
>
<Download :size="18" />
</button>
<button
class="modal-action-btn fullscreen-action-btn"
@click="closeFullscreenPreview"
title="关闭"
>
<X :size="18" />
</button>
</div>
<div class="fullscreen-preview-content">
<div class="file-content fullscreen-file-content">
<template v-if="file?.previewType === 'image' && file?.previewUrl">
<div class="image-preview-wrapper fullscreen-image-preview-wrapper">
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
</div>
</template>
<template v-else-if="file?.previewType === 'pdf' && file?.previewUrl">
<iframe
:src="file.previewUrl"
class="pdf-preview fullscreen-embed-preview"
:title="filePath"
/>
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
:key="`fullscreen-${htmlPreviewRenderKey}`"
class="html-preview fullscreen-embed-preview"
:srcdoc="htmlPreviewFullscreenSrcdoc"
:title="filePath"
sandbox="allow-scripts"
/>
</template>
<template v-else-if="isMarkdown">
<MarkdownPreview :content="formatContent(file?.content)" />
</template>
<template v-else-if="file?.supported === false">
<div class="unsupported-preview fullscreen-unsupported-preview">
{{ file?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre v-if="Array.isArray(file?.content)" class="file-content-pre">{{
formatContent(file.content)
}}</pre>
<pre
v-else-if="isCodePreview && typeof file?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof file?.content === 'string'" class="file-content-pre">{{
file.content
}}</pre>
<pre v-else>{{ JSON.stringify(file, null, 2) }}</pre>
</template>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import {
Code2,
Download,
Globe,
Maximize,
PanelRightClose,
FilePen,
Save,
X
} from 'lucide-vue-next'
import hljs from 'highlight.js/lib/common'
import MarkdownPreview from '@/components/common/MarkdownPreview.vue'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
import { useThemeStore } from '@/stores/theme'
import { escapeHtml } from '@/utils/html'
import {
getCodeLanguageByPath,
getPreviewFileExtension,
isHtmlPreview,
isMarkdownPreview
} from '@/utils/file_preview'
const EDITABLE_EXTENSIONS = new Set(['.md', '.markdown', '.mdx', '.txt'])
const HTML_PREVIEW_SCALE = 0.75
const HTML_PREVIEW_FULLSCREEN_SCALE = 1
const props = defineProps({
file: {
type: Object,
default: null
},
filePath: {
type: String,
default: ''
},
showHeader: {
type: Boolean,
default: true
},
showDownload: {
type: Boolean,
default: true
},
showClose: {
type: Boolean,
default: false
},
showFullscreen: {
type: Boolean,
default: false
},
showInlineHtmlControls: {
type: Boolean,
default: false
},
closeVariant: {
type: String,
default: 'close',
validator: (value) => ['close', 'collapse-right'].includes(value)
},
fullHeight: {
type: Boolean,
default: false
},
showFileIcon: {
type: Boolean,
default: true
},
borderless: {
type: Boolean,
default: false
},
editable: {
type: Boolean,
default: false
},
editAllText: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
containerClass: {
type: [String, Array, Object],
default: ''
},
contentClass: {
type: [String, Array, Object],
default: ''
}
})
const emit = defineEmits(['close', 'download', 'save'])
const themeStore = useThemeStore()
const closeTitle = computed(() =>
props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览'
)
const closeIconComponent = computed(() =>
props.closeVariant === 'collapse-right' ? PanelRightClose : X
)
const htmlPreviewMode = ref('render')
const editMode = ref('preview')
const draftContent = ref('')
const fullscreenPreviewVisible = ref(false)
const htmlPreviewRenderKey = ref(0)
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
const canEdit = computed(() => {
const previewType = props.file?.previewType
return (
props.editable &&
props.file?.supported !== false &&
typeof props.file?.content === 'string' &&
(previewType === 'text' || previewType === 'markdown') &&
(props.editAllText || EDITABLE_EXTENSIONS.has(getPreviewFileExtension(props.filePath)))
)
})
const savedContent = computed(() => formatContent(props.file?.content))
const draftChanged = computed(() => draftContent.value !== savedContent.value)
const isHtmlFile = computed(
() =>
['text', 'html'].includes(props.file?.previewType) &&
typeof props.file?.content === 'string' &&
isHtmlPreview(props.filePath)
)
const htmlPreviewSrcdoc = computed(() =>
buildHtmlPreviewSrcdoc(props.file?.content, HTML_PREVIEW_SCALE)
)
const htmlPreviewFullscreenSrcdoc = computed(() =>
buildHtmlPreviewSrcdoc(props.file?.content, HTML_PREVIEW_FULLSCREEN_SCALE)
)
const codeThemeClass = computed(() => (themeStore.isDark ? 'hljs-theme-dark' : 'hljs-theme-light'))
const codeLanguage = computed(() => getCodeLanguageByPath(props.filePath))
const isCodePreview = computed(
() =>
props.file?.previewType === 'text' &&
typeof props.file?.content === 'string' &&
!isHtmlFile.value &&
Boolean(codeLanguage.value)
)
const highlightedCodeContent = computed(() => {
const content = props.file?.content
if (!isCodePreview.value || typeof content !== 'string') {
return ''
}
try {
if (codeLanguage.value) {
return hljs.highlight(content, { language: codeLanguage.value }).value
}
return hljs.highlightAuto(content).value
} catch (error) {
console.warn('代码高亮失败:', error)
return escapeHtml(content)
}
})
const formatContent = (content) => {
if (Array.isArray(content)) return content.join('\n')
if (content === undefined || content === null) return ''
return String(content)
}
const serializeDoctype = (doctype) => {
if (!doctype) return ''
const publicId = doctype.publicId ? ` PUBLIC "${doctype.publicId}"` : ''
const systemId = doctype.systemId ? ` "${doctype.systemId}"` : ''
return `<!DOCTYPE ${doctype.name}${publicId}${systemId}>`
}
const buildHtmlPreviewSrcdoc = (content, scale = HTML_PREVIEW_SCALE) => {
const html = formatContent(content)
if (!html.trim() || typeof DOMParser === 'undefined') return html
const doc = new DOMParser().parseFromString(html, 'text/html')
if (scale !== 1) {
const style = doc.createElement('style')
style.setAttribute('data-yuxi-html-preview-scale', String(scale))
style.textContent = `html { zoom: ${scale} !important; }`
doc.head.append(style)
}
return `${serializeDoctype(doc.doctype)}${doc.documentElement.outerHTML}`
}
const syncDraftContent = () => {
draftContent.value = savedContent.value
editMode.value = 'preview'
}
const requestSave = () => {
if (!canEdit.value || props.saving) return
emit('save', draftContent.value)
}
const cancelEdit = () => {
syncDraftContent()
}
const openFullscreenPreview = () => {
if (!props.file) return
fullscreenPreviewVisible.value = true
}
const closeFullscreenPreview = () => {
fullscreenPreviewVisible.value = false
}
watch(
() => props.filePath,
() => {
htmlPreviewMode.value = 'render'
}
)
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, {
immediate: true
})
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
if (isHtmlFile.value) {
htmlPreviewRenderKey.value += 1
}
})
watch(fullscreenPreviewVisible, (visible) => {
document.body.style.overflow = visible ? 'hidden' : ''
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<style scoped lang="less">
.agent-file-preview {
position: relative;
min-width: 0;
border-radius: 8px;
overflow: hidden;
max-height: 90vh;
display: flex;
flex-direction: column;
min-height: 0;
}
.agent-file-preview.is-full-height {
max-height: 100vh;
}
.agent-file-preview.is-full-height .file-content {
min-height: 0;
}
.agent-file-preview.is-borderless {
border-radius: 0;
}
.preview-header {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 12px;
border-bottom: 1px solid var(--gray-150);
background: var(--gray-25);
}
.file-title,
.modal-actions,
.preview-mode-switch {
display: flex;
align-items: center;
}
.file-title {
gap: 8px;
min-width: 0;
}
.modal-actions,
.preview-mode-switch {
gap: 8px;
}
.preview-mode-switch {
padding: 2px;
border-radius: 8px;
background: var(--gray-100);
}
.inline-html-preview-switch {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.file-path-title {
font-weight: 400;
color: var(--gray-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-action-btn,
.preview-mode-btn {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gray-600);
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.modal-action-btn:hover,
.preview-mode-btn:hover {
background: var(--gray-100);
color: var(--gray-900);
}
.modal-action-btn:disabled,
.preview-mode-btn:disabled {
color: var(--gray-300);
cursor: not-allowed;
}
.modal-action-btn:disabled:hover,
.preview-mode-btn:disabled:hover {
background: transparent;
color: var(--gray-300);
}
.preview-mode-btn.active {
background: var(--gray-0);
color: var(--gray-900);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.text-mode-btn {
width: auto;
min-width: 48px;
padding: 0 8px;
font-size: 12px;
}
.file-content {
flex: 1 1 auto;
min-height: 300px;
min-width: 0;
overflow-y: auto;
border-radius: 0px;
&.is-editing {
overflow: hidden;
}
&.is-iframe-preview {
overflow: hidden;
}
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--gray-50);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 4px;
&:hover {
background: var(--gray-400);
}
}
.flat-md-preview {
padding: 16px calc(var(--page-padding) - 4px);
font-size: 0.85rem;
}
}
.edit-floating-actions {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
display: flex;
align-items: center;
gap: 8px;
pointer-events: none;
}
.agent-file-preview.has-preview-header .edit-floating-actions {
top: 52px;
}
.edit-status-badge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border: 1px solid var(--color-warning-100);
border-radius: 999px;
background: var(--color-warning-50);
font-size: 11px;
line-height: 1;
color: var(--color-warning-700);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
pointer-events: auto;
white-space: nowrap;
}
.edit-floating-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--gray-150);
border-radius: 50%;
background: var(--gray-0);
color: var(--gray-700);
cursor: pointer;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.14);
pointer-events: auto;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
border-color: var(--gray-250);
background: var(--gray-50);
color: var(--gray-900);
}
&:disabled {
color: var(--gray-300);
cursor: not-allowed;
opacity: 0.7;
&:hover {
border-color: var(--gray-150);
background: var(--gray-0);
}
}
}
.edit-floating-btn-primary {
background: var(--color-primary-500);
border-color: var(--color-primary-500);
color: #fff;
&:hover:not(:disabled) {
background: var(--color-primary-700);
border-color: var(--color-primary-700);
color: #fff;
}
&:disabled {
background: var(--color-primary-500);
border-color: var(--color-primary-500);
color: rgba(255, 255, 255, 0.5);
cursor: not-allowed;
&:hover {
background: var(--color-primary-500);
border-color: var(--color-primary-500);
color: rgba(255, 255, 255, 0.5);
}
}
}
.edit-floating-btn-danger {
&:hover:not(:disabled) {
border-color: var(--color-error-500);
background: var(--color-error-50);
color: var(--color-error-700);
}
}
.file-edit-textarea {
width: 100%;
min-height: 100%;
padding: 12px;
border: 0;
outline: none;
resize: none;
background: var(--gray-0);
color: var(--gray-1000);
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.5;
}
.file-edit-textarea:disabled {
color: var(--gray-600);
background: var(--gray-25);
}
.file-content pre,
.file-content-pre {
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--gray-1000);
background: transparent;
padding: 12px;
}
.file-content-pre.code-highlight {
border-radius: 8px;
background: var(--gray-0);
white-space: pre;
overflow-x: auto;
}
.file-content-pre.code-highlight code {
display: block;
white-space: pre;
color: inherit;
min-height: calc(80vh - 40px);
background: var(--gray-0);
}
.image-preview-wrapper {
display: flex;
justify-content: center;
align-items: flex-start;
}
.image-preview {
display: block;
max-width: 100%;
height: 100%;
max-height: calc(80vh - 32px);
object-fit: contain;
border-radius: 6px;
}
.pdf-preview {
width: 100%;
height: 100%;
min-height: calc(80vh - 40px);
border: none;
border-radius: 6px;
background: var(--gray-25);
}
.html-preview {
display: block; // 消除 iframe 行内基线间隙导致的底部白边
width: 100%;
height: 100%; // 适应父容器高度,而非固定 100vh
min-height: 0; // 移除固定最小高度,避免短内容白边
border: none;
border-radius: 0px;
background: var(--gray-0); // 跟随主题:亮色为白、暗色为近黑,避免暗色 HTML 底部露出白边
}
.unsupported-preview {
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--gray-600);
font-size: 14px;
line-height: 1.6;
height: 100%;
white-space: pre-wrap;
}
.fullscreen-preview-overlay {
position: fixed;
inset: 0;
z-index: 1200;
background: var(--gray-0);
}
.fullscreen-preview-actions {
position: fixed;
top: 16px;
right: 16px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.fullscreen-preview-switch {
display: inline-flex;
align-items: center;
gap: 2px;
min-height: 40px;
padding: 4px;
border: 1px solid var(--gray-200);
border-radius: 999px;
background: var(--color-trans-light);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
backdrop-filter: blur(10px);
.preview-mode-btn {
width: 32px;
height: 32px;
border-radius: 999px;
}
}
.fullscreen-action-btn {
width: 40px;
height: 40px;
border-radius: 999px;
background: var(--color-trans-light);
border: 1px solid var(--gray-200);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
backdrop-filter: blur(10px);
}
.fullscreen-preview-content {
position: absolute;
inset: 0;
min-height: 0;
}
.fullscreen-file-content {
height: 100vh;
max-height: none;
min-height: 100vh;
padding: 0px;
border-radius: 0;
}
.fullscreen-image-preview-wrapper {
min-height: calc(100vh - 48px);
align-items: center;
}
.fullscreen-embed-preview {
height: 100vh; // 全屏时填满视口
min-height: 100vh; // 确保全屏时不塌陷
border-radius: 0px;
}
.fullscreen-unsupported-preview {
min-height: calc(100vh - 48px);
}
.fullscreen-file-content .file-content-pre.code-highlight code {
min-height: calc(100vh - 48px);
}
.fullscreen-file-content .image-preview {
max-height: calc(100vh - 48px);
}
</style>
+340
View File
@@ -0,0 +1,340 @@
<template>
<MessageInputComponent
ref="inputRef"
:model-value="modelValue"
@update:modelValue="updateValue"
:is-loading="isLoading"
:disabled="disabled"
:send-button-disabled="sendButtonDisabled"
:placeholder="placeholder"
:mention="mention"
:thread-id="threadId"
:file-upload-enabled="supportsFileUpload"
@send="handleSend"
@keydown="handleKeyDown"
@paste-image="handlePastedImage"
@drop-files="handleDroppedFiles"
>
<template #top>
<div v-if="currentImage || previewAttachments.length" class="input-top-stack">
<ImagePreviewComponent
v-if="currentImage"
:image-data="currentImage"
@remove="handleImageRemoved"
class="image-preview-wrapper"
/>
<div v-if="previewAttachments.length" class="attachment-preview-list">
<div
v-for="attachment in previewAttachments"
:key="attachment.fileId"
class="attachment-file-card"
>
<div class="attachment-file-icon">
<FileTypeIcon :name="attachment.name" :size="18" />
</div>
<div class="attachment-file-body">
<div class="attachment-file-name" :title="attachment.name">{{ attachment.name }}</div>
<div class="attachment-file-meta">{{ attachment.meta }}</div>
</div>
<button
class="attachment-remove-btn"
type="button"
:aria-label="`移除附件 ${attachment.name}`"
@click.stop="handleAttachmentRemoved(attachment)"
>
<X :size="14" />
</button>
</div>
</div>
</div>
</template>
<template #options-left>
<AttachmentOptionsComponent
v-if="supportsFileUpload"
:disabled="disabled"
@upload="handleAttachmentUpload"
@upload-image="handleImageUpload"
@upload-image-success="handleImageUploadSuccess"
/>
</template>
<template #actions-left>
<div class="input-actions-left">
<slot name="actions-left-extra"></slot>
</div>
</template>
<template #actions-right>
<div class="input-actions-right">
<slot name="actions-right-extra"></slot>
</div>
</template>
</MessageInputComponent>
</template>
<script setup>
import { computed, ref } from 'vue'
import MessageInputComponent from '@/components/MessageInputComponent.vue'
import ImagePreviewComponent from '@/components/ImagePreviewComponent.vue'
import AttachmentOptionsComponent from '@/components/AttachmentOptionsComponent.vue'
import { X } from 'lucide-vue-next'
import { normalizeAttachmentPreviews } from '@/utils/file_utils'
import { uploadMultimodalImage } from '@/utils/multimodal_image_upload'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
sendButtonDisabled: { type: Boolean, default: false },
mention: { type: Object, default: () => null },
threadId: { type: String, default: '' },
supportsFileUpload: { type: Boolean, default: false },
attachments: {
type: Array,
default: () => []
}
})
const emit = defineEmits([
'update:modelValue',
'send',
'keydown',
'upload-attachment',
'remove-attachment'
])
const inputRef = ref(null)
const currentImage = ref(null)
const placeholder = '问点什么?使用 @ 可以提及哦~'
const previewAttachments = computed(() => normalizeAttachmentPreviews(props.attachments))
const updateValue = (val) => {
emit('update:modelValue', val)
}
const handleAttachmentUpload = (files = []) => {
emit('upload-attachment', files)
}
const handleImageUpload = (imageData) => {
if (imageData && imageData.success) {
currentImage.value = imageData
}
}
const handlePastedImage = async (file) => {
if (props.disabled || !props.supportsFileUpload) return
try {
const imageData = await uploadMultimodalImage(file)
handleImageUpload(imageData)
} catch (error) {
console.error('图片上传失败:', error)
}
}
const handleDroppedFiles = (files = []) => {
if (props.disabled || !props.supportsFileUpload || !files.length) return
handleAttachmentUpload(files)
}
const handleImageUploadSuccess = () => {
if (inputRef.value) {
inputRef.value.closeOptions()
}
}
const handleImageRemoved = () => {
currentImage.value = null
}
const handleAttachmentRemoved = (attachment) => {
emit('remove-attachment', attachment.raw)
}
const handleSend = () => {
emit('send', { image: currentImage.value })
currentImage.value = null
}
const handleKeyDown = (e) => {
if (props.sendButtonDisabled) {
return
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
} else {
emit('keydown', e)
}
}
defineExpose({
focus: () => inputRef.value?.focus(),
closeOptions: () => inputRef.value?.closeOptions()
})
</script>
<style lang="less" scoped>
.input-actions-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.input-actions-right {
display: flex;
align-items: center;
margin-right: 8px;
gap: 2px;
}
.input-top-stack {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.attachment-preview-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.attachment-file-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 220px;
min-width: 0;
padding: 10px 34px 10px 12px;
border: 1px solid var(--gray-150);
border-radius: 12px;
background: var(--gray-0);
box-shadow: 0 1px 4px var(--shadow-0);
}
.attachment-file-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--main-700);
background: var(--main-30);
}
.attachment-file-body {
min-width: 0;
}
.attachment-file-name {
overflow: hidden;
color: var(--gray-900);
font-size: 14px;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-file-meta {
margin-top: 2px;
color: var(--gray-500);
font-size: 12px;
line-height: 1.3;
}
.attachment-remove-btn {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--gray-0);
background: var(--gray-900);
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
background: var(--gray-700);
}
&:active {
transform: scale(0.96);
}
}
// 输入框操作按钮通用样式(穿透到 slot 内容)
:deep(.input-action-btn) {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
height: 30px;
border-radius: 8px;
font-size: 13px;
color: var(--gray-600);
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
background: transparent;
border: none;
&:hover {
color: var(--gray-900);
background: var(--gray-50);
}
&.active {
color: var(--gray-900);
background: var(--gray-100);
font-weight: 500;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
span {
line-height: 1;
}
}
// slot 内容的 hide-text 响应式样式
:deep(.hide-text) {
@media (max-width: 768px) {
display: none;
}
}
@media (max-width: 768px) {
.input-top-stack {
gap: 8px;
margin-bottom: 10px;
}
.attachment-file-card {
width: min(220px, 100%);
}
}
</style>
@@ -0,0 +1,698 @@
<template>
<div v-if="message.type === 'human' && message.image_content" class="message-image">
<img
:src="`data:${messageImageMimeType};base64,${message.image_content}`"
alt="用户上传的图片"
@click="
openImagePreview(
`data:${messageImageMimeType};base64,${message.image_content}`,
'用户上传的图片'
)
"
/>
</div>
<div
class="message-box"
:class="[
message.type,
customClasses,
{ 'has-attachments': message.type === 'human' && messageAttachments.length }
]"
>
<!-- 用户消息 -->
<div
v-if="message.type === 'human'"
class="message-copy-btn human-copy"
@click="copyToClipboard(message.content)"
:class="{ 'is-copied': isCopied }"
>
<Check v-if="isCopied" size="14" />
<Copy v-else size="14" />
</div>
<p v-if="message.type === 'human'" class="message-text">
<MentionTextRenderer :content="message.content" :display-labels="mentionDisplayLabels" />
</p>
<p v-else-if="message.type === 'system'" class="message-text-system">{{ message.content }}</p>
<!-- 助手消息 -->
<div v-else-if="message.type === 'ai'" class="assistant-message">
<div v-if="parsedData.reasoning_content" class="reasoning-box">
<a-collapse v-model:activeKey="reasoningActiveKey" :bordered="false">
<template #expandIcon="{ isActive }">
<caret-right-outlined :rotate="isActive ? 90 : 0" />
</template>
<a-collapse-panel
key="show"
:header="message.status == 'reasoning' ? '正在思考...' : '推理过程'"
class="reasoning-header"
>
<p class="reasoning-content">{{ parsedData.reasoning_content }}</p>
</a-collapse-panel>
</a-collapse>
</div>
<!-- 消息内容 -->
<MarkdownPreview
v-if="parsedData.content"
:key="message.id"
:content="parsedData.content"
code-copy
class="message-md"
/>
<div v-else-if="parsedData.reasoning_content" class="empty-block"></div>
<!-- 错误提示块 -->
<div v-if="displayError" class="error-hint">
<span v-if="getErrorMessage">{{ getErrorMessage }}</span>
<span v-else-if="message.error_type === 'interrupted'">回答生成已中断</span>
<span v-else-if="message.error_type === 'unexpect'">生成过程中出现异常</span>
<span v-else-if="message.error_type === 'content_guard_blocked'"
>检测到敏感内容已中断输出</span
>
<span v-else>{{ message.error_type || '未知错误' }}</span>
</div>
<ToolCallsGroupComponent
v-if="!hideToolCalls && validToolCalls.length > 0"
:tool-calls="validToolCalls"
/>
<div v-if="message.isStoppedByUser" class="retry-hint">
你停止生成了本次回答
<span class="retry-link" @click="emit('retryStoppedMessage', message.id)"
>重新编辑问题</span
>
</div>
<div
v-if="
(message.role == 'received' || message.role == 'assistant') &&
message.status == 'finished' &&
showRefs
"
>
<RefsComponent
:message="message"
:show-refs="showRefs"
:is-latest-message="isLatestMessage"
:sources="messageSources"
@retry="emit('retry')"
@openRefs="emit('openRefs', $event)"
/>
</div>
<!-- 错误消息 -->
</div>
<div v-if="infoStore.debugMode" class="status-info">{{ message }}</div>
<!-- 自定义内容 -->
<slot></slot>
</div>
<div
v-if="message.type === 'human' && messageAttachments.length"
class="human-message-attachments"
>
<div
v-for="attachment in messageAttachments"
:key="attachment.fileId"
class="message-attachment-file"
>
<div class="message-attachment-icon">
<FileTypeIcon :name="attachment.name" :size="18" />
</div>
<div class="message-attachment-body">
<div class="message-attachment-name" :title="attachment.name">
{{ attachment.name }}
</div>
<div class="message-attachment-meta">{{ attachment.meta }}</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="imagePreview.visible"
class="message-image-preview-overlay"
@click="closeImagePreview"
>
<button class="message-image-preview-close" title="关闭" @click.stop="closeImagePreview">
<X :size="20" />
</button>
<img :src="imagePreview.src" :alt="imagePreview.alt" class="message-image-preview-img" />
</div>
</Teleport>
</template>
<script setup>
import { computed, ref, onUnmounted } from 'vue'
import { CaretRightOutlined } from '@ant-design/icons-vue'
import RefsComponent from '@/components/RefsComponent.vue'
import { Copy, Check, X } from 'lucide-vue-next'
import ToolCallsGroupComponent from '@/components/ToolCallsGroupComponent.vue'
import MarkdownPreview from '@/components/common/MarkdownPreview.vue'
import MentionTextRenderer from '@/components/common/MentionTextRenderer.vue'
import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { inferImageMimeTypeFromBase64, normalizeAttachmentPreviews } from '@/utils/file_utils'
import { buildMentionDisplayLabels } from '@/utils/mention_utils'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
import { enrichTaskToolCalls } from '@/components/ToolCallingResult/toolRegistry'
const props = defineProps({
// 消息角色:'user'|'assistant'|'sent'|'received'
message: {
type: Object,
required: true
},
// 是否正在处理中
isProcessing: {
type: Boolean,
default: false
},
// 自定义类
customClasses: {
type: Object,
default: () => ({})
},
// 是否显示推理过程
showRefs: {
type: [Array, Boolean],
default: () => false
},
// 是否为最新消息
isLatestMessage: {
type: Boolean,
default: false
},
hideToolCalls: {
type: Boolean,
default: false
},
mention: {
type: Object,
default: () => null
},
// 是否显示调试信息 (已废弃,使用 infoStore.debugMode)
debugMode: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['retry', 'retryStoppedMessage', 'openRefs'])
// 图片全屏预览
const imagePreview = ref({ visible: false, src: '', alt: '' })
const handleImagePreviewKeydown = (e) => {
if (e.key === 'Escape') {
closeImagePreview()
}
}
const openImagePreview = (src, alt = '') => {
if (!src) return
imagePreview.value = { visible: true, src, alt }
window.addEventListener('keydown', handleImagePreviewKeydown)
}
const closeImagePreview = () => {
imagePreview.value = { visible: false, src: '', alt: '' }
window.removeEventListener('keydown', handleImagePreviewKeydown)
}
onUnmounted(() => {
window.removeEventListener('keydown', handleImagePreviewKeydown)
})
// 复制状态
const isCopied = ref(false)
const copyToClipboard = async (text) => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
} else {
// 降级处理:使用传统的 execCommand 方法
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (!successful) throw new Error('execCommand failed')
}
isCopied.value = true
setTimeout(() => {
isCopied.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy: ', err)
}
}
// 推理面板展开状态
const reasoningActiveKey = ref(['hide'])
// 错误消息处理
const displayError = computed(() => {
// 简化错误判断:只检查明确的错误类型标识
return !!(props.message.error_type || props.message.extra_metadata?.error_type)
})
const getErrorMessage = computed(() => {
// 优先使用直接的 error_message 字段
if (props.message.error_message) {
return props.message.error_message
}
// 其次从 extra_metadata 中获取具体的错误信息
if (props.message.extra_metadata?.error_message) {
return props.message.extra_metadata.error_message
}
// 对于已知的错误类型,返回默认提示
switch (props.message.error_type) {
case 'interrupted':
return '回答生成已中断'
case 'content_guard_blocked':
return '检测到敏感内容,已中断输出'
case 'unexpect':
return '生成过程中出现异常'
case 'agent_error':
return '智能体获取失败'
default:
return null
}
})
// 引入智能体 store
const agentStore = useAgentStore()
const { availableKnowledgeBases } = storeToRefs(agentStore)
const infoStore = useInfoStore()
const messageAttachments = computed(() =>
normalizeAttachmentPreviews(props.message.extra_metadata?.attachments)
)
const messageImageMimeType = computed(
() => inferImageMimeTypeFromBase64(props.message.image_content) || 'image/jpeg'
)
const mentionDisplayLabels = computed(() => buildMentionDisplayLabels(props.mention || {}))
const messageSources = computed(() => {
if (props.message.type === 'ai') {
return MessageProcessor.extractSourcesFromMessage(props.message, availableKnowledgeBases.value)
}
return { knowledgeChunks: [], webSources: [] }
})
const validToolCalls = computed(() => enrichTaskToolCalls(props.message.tool_calls))
const parsedData = computed(() => {
const { content, reasoningContent } = MessageProcessor.parseAssistantMessageBody(props.message)
return {
content,
reasoning_content: reasoningContent
}
})
</script>
<style lang="less" scoped>
.message-box {
display: inline-block;
border-radius: 1.5rem;
margin: 0.8rem 0;
padding: 0.625rem 1.25rem;
user-select: text;
word-break: break-word;
word-wrap: break-word;
font-size: 15px;
line-height: 24px;
box-sizing: border-box;
color: var(--gray-10000);
max-width: 100%;
position: relative;
letter-spacing: 0.25px;
&.human,
&.sent {
max-width: 95%;
color: var(--gray-1000);
background-color: var(--main-50);
align-self: flex-end;
border-radius: 0.5rem;
padding: 0.5rem 1rem;
}
&.assistant,
&.received,
&.ai {
color: initial;
width: 100%;
text-align: left;
margin: 0;
padding: 0px;
background-color: transparent;
border-radius: 0;
}
.message-text {
max-width: 100%;
margin-bottom: 0;
white-space: pre-line;
}
&.human.has-attachments,
&.sent.has-attachments {
margin-bottom: 0.375rem;
}
.message-copy-btn {
cursor: pointer;
color: var(--gray-400);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
flex-shrink: 0;
&:hover {
color: var(--main-color);
}
&.is-copied {
color: var(--color-success-500);
opacity: 1;
}
&.human-copy {
position: absolute;
left: -28px;
bottom: 8px;
}
}
&:hover {
.message-copy-btn {
opacity: 1;
}
}
.message-text-system {
max-width: 100%;
margin-bottom: 0;
white-space: pre-line;
color: var(--gray-600);
font-style: italic;
font-size: 14px;
padding: 8px 12px;
background-color: var(--gray-50);
border-left: 3px solid var(--gray-300);
border-radius: 4px;
}
.err-msg {
color: var(--color-error-500);
border: 1px solid currentColor;
padding: 0.5rem 1rem;
border-radius: 8px;
text-align: left;
background: var(--color-error-50);
margin-bottom: 10px;
cursor: pointer;
}
.searching-msg {
color: var(--gray-700);
animation: colorPulse 1s infinite ease-in-out;
}
.reasoning-box {
margin-top: 10px;
margin-bottom: 15px;
border-radius: 8px;
border: 1px solid var(--gray-150);
background-color: var(--gray-25);
overflow: hidden;
transition: all 0.2s ease;
:deep(.ant-collapse) {
background-color: transparent;
border: none;
.ant-collapse-item {
border: none;
.ant-collapse-header {
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
color: var(--gray-700);
transition: all 0.2s ease;
.ant-collapse-expand-icon {
color: var(--gray-400);
}
}
.ant-collapse-content {
border: none;
background-color: transparent;
.ant-collapse-content-box {
padding: 16px;
background-color: var(--gray-25);
}
}
}
}
.reasoning-content {
font-size: 13px;
color: var(--gray-800);
white-space: pre-wrap;
margin: 0;
line-height: 1.6;
}
}
.assistant-message {
width: 100%;
}
.error-hint {
margin: 10px 0;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
background-color: var(--color-error-50);
color: var(--color-error-500);
span {
line-height: 1.5;
}
}
.status-info {
display: block;
background-color: var(--gray-50);
color: var(--gray-700);
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 12px;
font-family: monospace;
max-height: 200px;
overflow-y: auto;
}
}
.human-message-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
align-self: flex-end;
max-width: 95%;
margin-bottom: 0.8rem;
}
.message-attachment-file {
width: 220px;
min-width: 0;
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--gray-200);
border-radius: 0.625rem;
background: var(--gray-0);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.message-attachment-icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.5rem;
color: var(--main-color);
background: var(--main-50);
}
.message-attachment-body {
min-width: 0;
flex: 1;
}
.message-attachment-name {
overflow: hidden;
color: var(--gray-900);
font-size: 0.875rem;
line-height: 1.25rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment-meta {
margin-top: 0.125rem;
color: var(--gray-500);
font-size: 0.75rem;
line-height: 1rem;
}
.retry-hint {
margin-top: 8px;
padding: 8px 16px;
color: var(--gray-600);
font-size: 14px;
text-align: left;
}
.retry-link {
color: var(--color-info-500);
cursor: pointer;
margin-left: 4px;
&:hover {
text-decoration: underline;
}
}
.ant-btn-icon-only {
&:has(.anticon-stop) {
background-color: var(--color-error-500) !important;
&:hover {
background-color: var(--color-error-100) !important;
}
}
}
@keyframes colorPulse {
0% {
color: var(--gray-700);
}
50% {
color: var(--gray-300);
}
100% {
color: var(--gray-700);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 多模态消息样式
.message-image {
border-radius: 12px;
overflow: hidden;
margin-left: auto;
/* max-height: 200px; */
border: 1px solid rgba(255, 255, 255, 0.2);
img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
cursor: pointer;
}
}
.message-md {
margin: 8px 0;
}
.message-image-preview-overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: rgba(0, 0, 0, 0.75);
cursor: zoom-out;
}
.message-image-preview-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
cursor: zoom-out;
}
.message-image-preview-close {
position: fixed;
top: 1.5rem;
right: 1.5rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
color: var(--gray-0);
background: rgba(255, 255, 255, 0.15);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.28);
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
<template>
<div class="ai-textarea-wrapper" :class="`action-${actionPlacement}`">
<a-textarea
:value="modelValue"
@update:value="$emit('update:modelValue', $event)"
:placeholder="placeholder"
:rows="rows"
:auto-size="autoSize"
/>
<a-tooltip v-if="name" title="使用 AI 生成或优化描述">
<a-button
class="ai-btn"
type="text"
size="small"
:loading="loading"
@click="generateDescription"
>
<template #icon>
<WandSparkles size="14" />
</template>
<span v-if="!loading" class="ai-text">{{ modelValue?.trim() ? '润色' : '生成' }}</span>
</a-button>
</a-tooltip>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { databaseApi } from '@/apis/knowledge_api'
import { WandSparkles } from 'lucide-vue-next'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
name: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
rows: {
type: Number,
default: 4
},
autoSize: {
type: [Boolean, Object],
default: false
},
files: {
type: Array,
default: () => []
},
actionPlacement: {
type: String,
default: 'inside'
}
})
const emit = defineEmits(['update:modelValue'])
const loading = ref(false)
const generateDescription = async () => {
if (!props.name?.trim()) {
message.warning('请先输入知识库名称')
return
}
loading.value = true
try {
const result = await databaseApi.generateDescription(props.name, props.modelValue, props.files)
if (result.status === 'success' && result.description) {
emit('update:modelValue', result.description)
message.success('描述生成成功')
} else {
message.error(result.message || '生成失败')
}
} catch (error) {
console.error('生成描述失败:', error)
message.error(error.message || '生成描述失败')
} finally {
loading.value = false
}
}
</script>
<style lang="less" scoped>
.ai-textarea-wrapper {
position: relative;
.ai-btn {
position: absolute;
opacity: 0.9;
top: 4px;
right: 4px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 54px;
padding: 2px 6px;
height: 24px;
color: var(--main-color);
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: 4px;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background: var(--main-10);
border-color: var(--main-color);
}
.ai-text {
font-weight: 500;
}
:deep(.ant-btn-loading-icon) {
display: inline-flex;
margin-inline-end: 0;
}
}
&.action-header {
.ai-btn {
top: -31px;
right: 0;
height: 26px;
min-width: 58px;
padding: 0 9px;
border-radius: 6px;
background: var(--gray-0);
border-color: var(--gray-150);
color: var(--main-700);
box-shadow: 0 1px 2px var(--shadow-1);
&:hover {
background: var(--gray-50);
border-color: var(--gray-200);
color: var(--gray-900);
}
}
}
}
</style>
@@ -0,0 +1,517 @@
<template>
<div class="apikey-management">
<!-- 头部区域 -->
<div class="header-section">
<div class="header-content">
<div class="section-title">API Key 管理</div>
<p class="section-description">
用于外部系统调用 Agent 对话接口密钥仅显示一次请妥善保管
</p>
</div>
<div class="header-actions">
<a-button
@click="handleRefresh"
:loading="refreshing"
title="刷新"
class="refresh-btn lucide-icon-btn"
>
<template #icon><RefreshCw :size="16" :class="{ spin: refreshing }" /></template>
</a-button>
<a-button type="primary" @click="showCreateModal" class="add-btn lucide-icon-btn">
<Plus :size="14" />
创建 API Key
</a-button>
</div>
</div>
<!-- 主内容区域 -->
<div class="content-section">
<a-spin :spinning="loading">
<div v-if="error" class="error-message">
<a-alert type="error" :message="error" show-icon />
</div>
<div class="cards-container">
<div v-if="apiKeys.length === 0" class="empty-state">
<a-empty description="暂无 API Key,点击上方按钮创建一个" />
</div>
<div v-else class="apikey-cards-grid">
<div v-for="key in apiKeys" :key="key.id" class="apikey-card">
<div class="card-header">
<div class="key-info">
<KeyIcon size="18" class="key-icon" />
<div class="key-info-content">
<h4 class="key-name">{{ key.name }}</h4>
</div>
</div>
<code class="key-prefix">{{ key.key_prefix }}****</code>
</div>
<div class="card-content">
<div class="info-item">
<span class="info-label">过期时间:</span>
<span class="info-value">{{ key.expires_at || '永不过期' }}</span>
</div>
<div class="info-item">
<span class="info-label">最后使用:</span>
<span class="info-value">{{ formatTime(key.last_used_at) }}</span>
</div>
</div>
<div class="card-footer">
<div class="footer-left">
<span class="switch-label">{{ key.is_enabled ? '已启用' : '已禁用' }}</span>
<a-switch :checked="key.is_enabled" size="small" @change="toggleEnabled(key)" />
</div>
<div class="footer-actions">
<a-tooltip title="重新生成(获取完整密钥)">
<a-button
type="text"
size="small"
@click="regenerateKey(key)"
class="action-btn lucide-icon-btn"
>
<RefreshCw :size="14" />
<span>重新生成</span>
</a-button>
</a-tooltip>
<a-popconfirm
title="确定要删除此 API Key 吗?此操作不可恢复。"
@confirm="deleteKey(key)"
ok-text="确定"
cancel-text="取消"
>
<a-tooltip title="删除">
<a-button type="text" size="small" danger class="action-btn lucide-icon-btn">
<Trash2 :size="14" />
<span>删除</span>
</a-button>
</a-tooltip>
</a-popconfirm>
</div>
</div>
</div>
</div>
</div>
</a-spin>
</div>
<!-- 创建 Modal -->
<a-modal
v-model:open="createModalVisible"
title="创建 API Key"
@ok="handleCreate"
:confirmLoading="createLoading"
ok-text="创建"
cancel-text="取消"
>
<a-form layout="vertical" :model="createForm">
<a-form-item label="名称" required>
<a-input v-model:value="createForm.name" placeholder="如:生产环境API" />
</a-form-item>
<a-form-item label="过期时间">
<a-date-picker
v-model:value="createForm.expires_at"
show-time
placeholder="留空表示永不过期"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 密钥显示 Modal (创建后一次性显示) -->
<a-modal
v-model:open="secretModalVisible"
title="API Key 已创建"
:closable="true"
@cancel="secretModalVisible = false"
:footer="null"
width="520px"
>
<div class="secret-display">
<a-alert
type="warning"
message="请立即复制密钥,关闭后将无法再次查看完整密钥"
show-icon
class="secret-alert"
/>
<div class="secret-value-container">
<code class="secret-value">{{ createdSecret }}</code>
<a-button type="primary" @click="copySecret" class="copy-btn lucide-icon-btn">
<Copy :size="14" />
复制
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { Plus, RefreshCw, Trash2, Copy } from 'lucide-vue-next'
import { Key as KeyIcon } from 'lucide-vue-next'
import { apikeyApi } from '@/apis/apikey_api'
const loading = ref(false)
const refreshing = ref(false)
const error = ref(null)
const apiKeys = ref([])
const createModalVisible = ref(false)
const secretModalVisible = ref(false)
const createLoading = ref(false)
const createdSecret = ref('')
const createForm = reactive({
name: '',
expires_at: null
})
const formatTime = (timeStr) => {
if (!timeStr) return '-'
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const loadApiKeys = async () => {
loading.value = true
error.value = null
try {
const res = await apikeyApi.list()
apiKeys.value = res.api_keys || []
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
// 刷新 API Key 列表
const handleRefresh = async () => {
if (refreshing.value) return
refreshing.value = true
try {
await loadApiKeys()
message.success('刷新成功')
} catch (e) {
console.error('刷新失败:', e)
message.error('刷新失败')
} finally {
refreshing.value = false
}
}
const showCreateModal = () => {
createForm.name = ''
createForm.expires_at = null
createModalVisible.value = true
}
const handleCreate = async () => {
if (!createForm.name.trim()) {
message.error('请输入名称')
return
}
createLoading.value = true
try {
const data = { name: createForm.name }
if (createForm.expires_at) {
data.expires_at = createForm.expires_at.format('YYYY-MM-DDTHH:mm:ss')
}
const res = await apikeyApi.create(data)
createdSecret.value = res.secret
createModalVisible.value = false
secretModalVisible.value = true
await loadApiKeys()
} catch (e) {
message.error(e.message || '创建失败')
} finally {
createLoading.value = false
}
}
const copySecret = async () => {
try {
await navigator.clipboard.writeText(createdSecret.value)
message.success('已复制到剪贴板')
} catch {
message.error('复制失败')
}
}
const regenerateKey = async (key) => {
try {
const res = await apikeyApi.regenerate(key.id)
createdSecret.value = res.secret
secretModalVisible.value = true
await loadApiKeys()
} catch (e) {
message.error(e.message || '重新生成失败')
}
}
const toggleEnabled = async (key) => {
try {
await apikeyApi.update(key.id, { is_enabled: !key.is_enabled })
message.success(key.is_enabled ? '已禁用' : '已启用')
await loadApiKeys()
} catch (e) {
message.error(e.message || '操作失败')
}
}
const deleteKey = async (key) => {
try {
await apikeyApi.delete(key.id)
message.success('删除成功')
await loadApiKeys()
} catch (e) {
message.error(e.message || '删除失败')
}
}
onMounted(() => {
loadApiKeys()
})
</script>
<style lang="less" scoped>
.apikey-management {
.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 12px;
}
.section-description {
font-size: 14px;
color: var(--gray-600);
line-height: 1.4;
margin: 0;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
background: var(--gray-25);
}
.spin {
animation: spin 1s linear infinite;
}
}
}
}
.content-section {
.error-message {
margin-bottom: 16px;
}
.cards-container {
.empty-state {
padding: 48px 0;
}
.apikey-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.apikey-card {
background: var(--gray-0);
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 12px;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: var(--gray-300);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.key-info {
display: flex;
align-items: center;
gap: 10px;
.key-icon {
color: var(--main-600);
flex-shrink: 0;
}
.key-info-content {
.key-name {
font-size: 14px;
font-weight: 600;
color: var(--gray-900);
margin: 0;
}
}
}
.key-prefix {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 12px;
color: var(--gray-600);
background: var(--gray-50);
padding: 2px 8px;
border-radius: 8px;
}
}
.card-content {
margin-bottom: 10px;
.info-item {
display: flex;
align-items: flex-start;
gap: 6px;
margin-bottom: 6px;
font-size: 13px;
&:last-child {
margin-bottom: 0;
}
.info-label {
color: var(--gray-600);
flex-shrink: 0;
}
.info-value {
color: var(--gray-900);
word-break: break-all;
}
&.half {
flex: 1;
}
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid var(--gray-100);
.footer-left {
display: flex;
align-items: center;
gap: 8px;
.switch-label {
font-size: 12px;
color: var(--gray-600);
}
}
.footer-actions {
display: flex;
gap: 4px;
}
.action-btn {
font-size: 12px;
color: var(--gray-700);
display: inline-flex;
align-items: center;
gap: 4px;
&:hover {
color: var(--main-600);
}
}
}
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.secret-display {
.secret-alert {
margin-bottom: 16px;
}
.secret-value-container {
display: flex;
gap: 8px;
align-items: stretch;
.secret-value {
flex: 1;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 13px;
background: var(--gray-100);
border: 1px solid var(--gray-200);
border-radius: 6px;
padding: 12px;
word-break: break-all;
color: var(--gray-900);
}
.copy-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
}
}
</style>
@@ -0,0 +1,134 @@
<template>
<div class="attachment-options">
<div class="option-item" :class="{ disabled: disabled }" @click="handleAttachmentClick">
<a-tooltip title="支持任意文件格式 ≤ 5 MB" placement="right">
<div class="option-content">
<FileText :size="14" class="option-icon" />
<span class="option-text">添加附件</span>
</div>
</a-tooltip>
</div>
<div class="option-item" @click="handleImageUpload">
<a-tooltip title="支持 jpg/jpeg/png/gif ≤ 5 MB" placement="right">
<div class="option-content">
<Image :size="14" class="option-icon" />
<span class="option-text">上传图片</span>
</div>
</a-tooltip>
</div>
</div>
</template>
<script setup>
import { FileText, Image } from 'lucide-vue-next'
import { message } from 'ant-design-vue'
import { uploadMultimodalImage } from '@/utils/multimodal_image_upload'
const props = defineProps({
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['upload', 'upload-image', 'upload-image-success'])
const handleAttachmentClick = () => {
if (props.disabled) return
emit('upload')
}
// 处理图片上传
const handleImageUpload = () => {
if (props.disabled) return
// 创建隐藏的文件输入
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.multiple = false
input.style.display = 'none'
input.onchange = async (event) => {
const file = event.target.files[0]
if (file) {
await processImageUpload(file)
}
document.body.removeChild(input)
}
document.body.appendChild(input)
input.click()
emit('upload-image')
}
// 处理图片上传逻辑
const processImageUpload = async (file) => {
try {
const imageData = await uploadMultimodalImage(file)
if (!imageData) return
// 发出上传成功事件,包含处理后的图片数据
emit('upload-image', imageData)
// 发出上传成功通知事件,用于关闭选项面板
emit('upload-image-success')
} catch (error) {
console.error('图片上传失败:', error)
message.error({
content: `图片上传失败: ${error.message || '未知错误'}`,
key: 'image-upload'
})
}
}
</script>
<style lang="less" scoped>
.attachment-options {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 120px;
}
.option-item {
cursor: pointer;
transition: all 0.2s ease;
&.disabled {
cursor: not-allowed;
opacity: 0.5;
.option-content {
color: var(--gray-400);
}
}
}
.option-content {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
color: var(--gray-700);
font-size: 12px;
border-radius: 6px;
transition: all 0.15s ease;
.option-item:hover & {
color: var(--main-color);
background-color: var(--gray-50);
}
}
.option-icon {
flex-shrink: 0;
color: inherit;
}
.option-text {
font-weight: 500;
}
</style>
@@ -0,0 +1,770 @@
<template>
<a-modal
:open="open"
title="添加附件"
ok-text="添加附件"
cancel-text="取消"
:confirm-loading="confirming"
:ok-button-props="{ disabled: confirmDisabled }"
@ok="handleConfirm"
@cancel="handleCancel"
>
<a-upload-dragger
:multiple="true"
:show-upload-list="false"
:before-upload="handleBeforeUpload"
:disabled="confirming"
class="attachment-dropzone"
>
<p class="dropzone-title">点击或拖拽文件到此处上传</p>
<p class="dropzone-desc">支持任意文件格式 5 MBPDF 和图片可选解析为 Markdown</p>
</a-upload-dragger>
<div v-if="fileItems.length" class="attachment-list">
<div v-for="item in fileItems" :key="item.localId" class="attachment-item">
<div class="attachment-file-icon">
<FileTypeIcon :name="item.fileName" :size="20" />
</div>
<div class="attachment-item-content">
<div class="attachment-name-row">
<span class="attachment-name" :title="item.fileName">{{ item.fileName }}</span>
<a-button
size="small"
type="text"
class="lucide-icon-btn remove-btn"
:disabled="confirming"
@click="removeItem(item.localId)"
>
<X :size="16" />
</a-button>
</div>
<div class="attachment-status-row">
<div class="attachment-status-meta">
<a-tag
:color="getStatusColor(item.status)"
:bordered="false"
class="attachment-status-tag"
>
{{ getStatusLabel(item.status) }}
</a-tag>
<span>{{ formatFileSize(item.fileSize) }}</span>
<span v-if="item.error" class="attachment-error">{{ item.error }}</span>
<span v-else-if="item.parseError" class="attachment-error">{{
item.parseError
}}</span>
</div>
<a-popover
v-if="item.parseSupported && item.status !== 'uploading' && item.status !== 'error'"
v-model:open="item.parsePanelOpen"
placement="bottomRight"
trigger="click"
overlayClassName="attachment-parse-popover"
@openChange="(open) => handleParsePanelOpenChange(item.localId, open)"
>
<template #content>
<div class="parse-panel">
<button
v-for="method in getAvailableParseMethods(item)"
:key="method"
type="button"
class="parse-method-option"
:class="{ selected: item.selectedParseMethod === method }"
:disabled="item.status === 'parsing' || confirming"
@click="handleParseMethodChange(item.localId, method)"
>
<span class="parse-method-option-header">
<span class="parse-method-name">{{ methodLabels[method] || method }}</span>
<span
class="parse-method-status"
:class="`status-${getMethodStatus(method)}`"
>
{{ getMethodStatusLabel(method) }}
</span>
</span>
<span class="parse-method-desc">{{ getMethodDescription(method) }}</span>
</button>
<div v-if="getUnavailableParseMethods(item).length" class="unavailable-methods">
<button
type="button"
class="unavailable-toggle"
@click="toggleUnavailableParseMethods(item.localId)"
>
<span>不可用选项{{ getUnavailableParseMethods(item).length }}</span>
<ChevronUp v-if="item.unavailableMethodsExpanded" :size="14" />
<ChevronDown v-else :size="14" />
</button>
<div v-if="item.unavailableMethodsExpanded" class="unavailable-method-list">
<button
v-for="method in getUnavailableParseMethods(item)"
:key="method"
type="button"
class="parse-method-option disabled"
disabled
>
<span class="parse-method-option-header">
<span class="parse-method-name">{{
methodLabels[method] || method
}}</span>
<span
class="parse-method-status"
:class="`status-${getMethodStatus(method)}`"
>
{{ getMethodStatusLabel(method) }}
</span>
</span>
<span class="parse-method-desc">{{ getMethodDescription(method) }}</span>
</button>
</div>
</div>
<a-button
type="primary"
size="small"
block
class="parse-start-btn"
:loading="item.status === 'parsing'"
:disabled="isParseDisabled(item)"
@click="handleStartParse(item.localId)"
>
开始解析
</a-button>
</div>
</template>
<a-button
size="small"
class="parse-trigger-btn"
:loading="item.status === 'parsing'"
:disabled="confirming"
>
可解析
</a-button>
</a-popover>
</div>
</div>
</div>
</div>
</a-modal>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { ChevronDown, ChevronUp, X } from 'lucide-vue-next'
import { threadApi } from '@/apis'
import { ocrApi } from '@/apis/system_api'
import { useConfigStore } from '@/stores/config'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
const props = defineProps({
open: { type: Boolean, default: false },
threadId: { type: String, default: '' },
ensureThread: { type: Function, default: null },
initialFiles: { type: Array, default: () => [] },
initialFilesKey: { type: Number, default: 0 }
})
const emit = defineEmits(['update:open', 'added'])
const configStore = useConfigStore()
const DEFAULT_OCR_ENGINE = 'rapid_ocr'
const fileItems = ref([])
const confirming = ref(false)
let localIdSeed = 0
let consumedInitialFilesKey = 0
const methodLabels = {
disable: 'PDF 文本提取',
rapid_ocr: 'RapidOCR',
mineru_ocr: 'MinerU OCR',
mineru_official: 'MinerU Official',
pp_structure_v3_ocr: 'PP-Structure V3',
deepseek_ocr: 'DeepSeek OCR',
paddleocr_vl_1_6: 'PaddleOCR-VL-1.6',
paddleocr_pp_ocrv6: 'PP-OCRv6'
}
const ocrMethodKeys = [
'rapid_ocr',
'mineru_ocr',
'mineru_official',
'pp_structure_v3_ocr',
'deepseek_ocr',
'paddleocr_vl_1_6',
'paddleocr_pp_ocrv6'
]
const defaultOcrHealthStatus = () =>
Object.fromEntries(ocrMethodKeys.map((method) => [method, { status: 'unknown', message: '' }]))
const ocrHealthStatus = ref(defaultOcrHealthStatus())
const ocrHealthChecking = ref(false)
const methodStatusLabels = {
local: '无需 OCR',
healthy: '可用',
configured: '已配置',
unavailable: '不可用',
unhealthy: '异常',
timeout: '超时',
error: '异常',
checking: '检查中',
unknown: '状态未知'
}
const busy = computed(() =>
fileItems.value.some((item) => ['uploading', 'parsing'].includes(item.status))
)
const confirmableItems = computed(() =>
fileItems.value.filter((item) => ['uploaded', 'parsed'].includes(item.status))
)
const confirmDisabled = computed(() => busy.value || confirmableItems.value.length === 0)
watch(
() => props.open,
(open) => {
if (!open) {
fileItems.value = []
confirming.value = false
}
}
)
const getErrorMessage = (error, fallback = '操作失败') => {
return error?.response?.data?.detail || error?.message || fallback
}
const getDefaultParseMethod = (parseMethods) => {
if (!Array.isArray(parseMethods) || parseMethods.length === 0) {
return null
}
const configuredEngine = String(
configStore.config?.default_ocr_engine || DEFAULT_OCR_ENGINE
).trim()
if (parseMethods.includes(configuredEngine)) {
return configuredEngine
}
if (parseMethods.includes(DEFAULT_OCR_ENGINE)) {
return DEFAULT_OCR_ENGINE
}
return parseMethods[0]
}
const normalizeTmpUpload = (response) => ({
tmpFileId: response.tmp_file_id,
fileName: response.file_name,
fileType: response.file_type,
fileSize: response.file_size,
bucketName: response.bucket_name,
objectName: response.object_name,
minioUrl: response.minio_url,
parseSupported: response.parse_supported,
parseMethods: response.parse_methods || [],
selectedParseMethod: getDefaultParseMethod(response.parse_methods || []),
parseMethodTouched: false
})
watch(
() => configStore.config?.default_ocr_engine,
() => {
fileItems.value = fileItems.value.map((item) => {
if (!item.parseSupported || item.parseMethodTouched || item.status === 'parsed') {
return item
}
return { ...item, selectedParseMethod: getDefaultParseMethod(item.parseMethods || []) }
})
}
)
const updateItem = (localId, patch) => {
fileItems.value = fileItems.value.map((item) =>
item.localId === localId ? { ...item, ...patch } : item
)
}
const checkOcrHealth = async () => {
if (ocrHealthChecking.value) return
ocrHealthChecking.value = true
try {
const healthData = await ocrApi.getHealth()
ocrHealthStatus.value = {
...defaultOcrHealthStatus(),
...(healthData?.services || {})
}
} catch (error) {
console.error('OCR健康检查失败:', error)
} finally {
ocrHealthChecking.value = false
}
}
const uploadFile = async (file) => {
const localId = `${Date.now()}-${localIdSeed++}`
const item = {
localId,
fileName: file.name,
fileSize: file.size,
status: 'uploading',
error: null,
parseError: null,
parseSupported: false,
parseMethods: []
}
fileItems.value.push(item)
try {
const response = await threadApi.uploadTmpAttachment(file)
const normalized = normalizeTmpUpload(response)
updateItem(localId, { ...normalized, status: 'uploaded' })
if (normalized.parseSupported) {
void checkOcrHealth()
}
} catch (error) {
updateItem(localId, {
status: 'error',
error: getErrorMessage(error, '上传失败')
})
}
}
const handleBeforeUpload = (file) => {
void uploadFile(file)
return false
}
const uploadInitialFiles = () => {
if (!props.open || !props.initialFilesKey) return
if (props.initialFilesKey === consumedInitialFilesKey) return
consumedInitialFilesKey = props.initialFilesKey
Array.from(props.initialFiles || [])
.filter((file) => file instanceof File)
.forEach((file) => {
void uploadFile(file)
})
}
watch(
() => [props.open, props.initialFilesKey],
() => {
uploadInitialFiles()
},
{ flush: 'post' }
)
const getMethodStatus = (method) => {
if (method === 'disable') return 'local'
const current = ocrHealthStatus.value?.[method]
if (ocrHealthChecking.value && (!current || current.status === 'unknown')) return 'checking'
return current?.status || 'unknown'
}
const getMethodStatusLabel = (method) => methodStatusLabels[getMethodStatus(method)] || '状态未知'
const getMethodDescription = (method) => {
if (method === 'disable') return '使用文件内置文本层,不调用 OCR 服务'
const messageText = ocrHealthStatus.value?.[method]?.message
if (messageText) return messageText
const status = getMethodStatus(method)
const fallbackMap = {
healthy: '服务正常',
configured: 'Token 已配置,将在解析时验证',
unavailable: '服务不可用',
unhealthy: '服务异常',
timeout: '服务检查超时',
error: '服务异常',
checking: '正在检查服务状态',
unknown: '服务状态未知'
}
return fallbackMap[status] || '服务状态未知'
}
const isUnavailableParseMethod = (method) =>
['unavailable', 'error'].includes(getMethodStatus(method))
const getAvailableParseMethods = (item) =>
(item.parseMethods || []).filter((method) => !isUnavailableParseMethod(method))
const getUnavailableParseMethods = (item) =>
(item.parseMethods || []).filter((method) => isUnavailableParseMethod(method))
const isParseDisabled = (item) =>
item.status === 'parsing' ||
!item.selectedParseMethod ||
confirming.value ||
isUnavailableParseMethod(item.selectedParseMethod)
const clearParsedState = {
parsedObjectName: null,
parsedMinioUrl: null,
truncated: false,
parseMethod: null
}
const handleParseMethodChange = (localId, selectedParseMethod) => {
const item = fileItems.value.find((entry) => entry.localId === localId)
updateItem(localId, {
...clearParsedState,
selectedParseMethod,
parseMethodTouched: true,
parseError: null,
status: item?.status === 'parsed' ? 'uploaded' : item?.status
})
}
const handleStartParse = (localId) => {
const item = fileItems.value.find((entry) => entry.localId === localId)
if (!item || isParseDisabled(item)) return
updateItem(localId, { parsePanelOpen: false })
void handleParse(item)
}
const handleParse = async (item) => {
if (!item.objectName || !item.selectedParseMethod) return
updateItem(item.localId, {
...clearParsedState,
status: 'parsing',
parseError: null
})
try {
const response = await threadApi.parseTmpAttachment({
object_name: item.objectName,
file_name: item.fileName,
bucket_name: item.bucketName,
parse_method: item.selectedParseMethod
})
updateItem(item.localId, {
status: 'parsed',
parsedObjectName: response.parsed_object_name,
parsedMinioUrl: response.parsed_minio_url,
truncated: response.truncated,
parseMethod: response.parse_method
})
message.success('附件解析完成')
} catch (error) {
updateItem(item.localId, {
...clearParsedState,
status: 'uploaded',
parseError: getErrorMessage(error, '解析失败')
})
}
}
const removeItem = (localId) => {
fileItems.value = fileItems.value.filter((item) => item.localId !== localId)
}
const toggleUnavailableParseMethods = (localId) => {
const item = fileItems.value.find((entry) => entry.localId === localId)
updateItem(localId, { unavailableMethodsExpanded: !item?.unavailableMethodsExpanded })
}
const handleParsePanelOpenChange = (localId, open) => {
updateItem(localId, { parsePanelOpen: open })
if (open) {
void checkOcrHealth()
}
}
const handleConfirm = async () => {
if (confirmDisabled.value) return
const attachments = confirmableItems.value.map((item) => ({
file_name: item.fileName,
file_type: item.fileType,
bucket_name: item.bucketName,
object_name: item.objectName,
parsed_object_name: item.parsedObjectName || null,
truncated: Boolean(item.truncated)
}))
confirming.value = true
try {
const threadId = props.threadId || (props.ensureThread ? await props.ensureThread() : '')
if (!threadId) {
message.error('创建对话失败,无法添加附件')
return
}
const response = await threadApi.confirmTmpThreadAttachments(threadId, attachments)
message.success('附件已添加')
emit('added', response)
emit('update:open', false)
} catch (error) {
message.error(getErrorMessage(error, '添加附件失败'))
} finally {
confirming.value = false
}
}
const handleCancel = () => {
emit('update:open', false)
}
const getStatusColor = (status) => {
const colorMap = {
uploading: 'processing',
uploaded: 'blue',
parsing: 'processing',
parsed: 'green',
error: 'red'
}
return colorMap[status] || 'default'
}
const getStatusLabel = (status) => {
const labelMap = {
uploading: '上传中',
uploaded: '已上传',
parsing: '解析中',
parsed: '已解析',
error: '失败'
}
return labelMap[status] || status
}
const formatFileSize = (size) => {
if (!Number.isFinite(size)) return '未知大小'
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
</script>
<style lang="less" scoped>
.attachment-dropzone {
margin-bottom: 0;
}
.dropzone-title {
margin: 0 0 4px;
color: var(--gray-800);
font-size: 14px;
font-weight: 600;
}
.dropzone-desc {
margin: 0;
color: var(--gray-500);
font-size: 12px;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 360px;
margin-top: 16px;
overflow: auto;
}
.attachment-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--gray-100);
border-radius: 10px;
background: var(--gray-50);
}
.attachment-file-icon {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--gray-0);
font-size: 18px;
}
.attachment-item-content {
min-width: 0;
flex: 1;
}
.attachment-name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.attachment-name {
flex: 1;
overflow: hidden;
color: var(--gray-900);
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-btn {
color: var(--gray-500);
}
.remove-btn:hover {
color: var(--color-error-500);
background: var(--color-error-50);
}
.attachment-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--gray-500);
font-size: 11px;
}
.attachment-status-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.attachment-status-tag {
min-height: 18px;
margin-inline-end: 0;
padding: 0 5px;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
}
.attachment-error {
color: var(--color-error-700);
}
.parse-trigger-btn {
flex: none;
margin-left: auto;
font-size: 12px;
}
.parse-panel {
display: flex;
flex-direction: column;
gap: 8px;
width: 260px;
}
.parse-method-option {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--gray-100);
border-radius: 8px;
background: var(--gray-0);
color: inherit;
cursor: pointer;
text-align: left;
}
.parse-method-option:hover:not(:disabled) {
border-color: var(--main-color);
background: color-mix(in srgb, var(--main-color) 6%, var(--gray-0));
}
.parse-method-option.selected {
border-color: var(--main-color);
background: color-mix(in srgb, var(--main-color) 8%, var(--gray-0));
}
.parse-method-option.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.unavailable-methods,
.unavailable-method-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.unavailable-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 2px;
border: none;
background: transparent;
color: var(--gray-500);
cursor: pointer;
font-size: 12px;
}
.unavailable-toggle:hover {
color: var(--gray-800);
}
.parse-method-option-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.parse-method-name {
color: var(--gray-900);
font-size: 13px;
font-weight: 500;
}
.parse-method-status {
flex: none;
font-size: 12px;
}
.parse-method-status.status-local,
.parse-method-status.status-healthy,
.parse-method-status.status-configured {
color: var(--color-success-700);
}
.parse-method-status.status-unavailable,
.parse-method-status.status-error {
color: var(--color-error-700);
}
.parse-method-status.status-unhealthy,
.parse-method-status.status-timeout,
.parse-method-status.status-unknown,
.parse-method-status.status-checking {
color: var(--color-warning-700);
}
.parse-method-desc {
color: var(--gray-500);
font-size: 12px;
line-height: 1.4;
}
.parse-start-btn {
margin-top: 2px;
}
:global(.attachment-parse-popover .ant-popover-inner-content) {
padding: 10px;
}
</style>
+365
View File
@@ -0,0 +1,365 @@
<template>
<div class="basic-settings-section">
<template v-if="userStore.isAdmin">
<div class="section-title">默认项配置</div>
<div class="settings-panel">
<template v-if="userStore.isSuperAdmin">
<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="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">
<RerankModelSelector
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
style="width: 100%"
/>
</div>
</div>
</div>
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">
{{ items?.default_ocr_engine?.des || '默认 OCR 解析引擎' }}
</div>
<div class="setting-content">
<a-select
:value="configStore.config?.default_ocr_engine || 'rapid_ocr'"
@change="handleChange('default_ocr_engine', $event)"
class="full-width"
>
<a-select-option
v-for="option in ocrEngineOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
</a-select>
</div>
</div>
</div>
</template>
</div>
<template v-if="userStore.isSuperAdmin">
<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>
</template>
</template>
<!-- 服务链接部分 -->
<div v-if="userStore.isAdmin" class="section-title">服务链接</div>
<div v-if="userStore.isAdmin">
<p class="section-description">
快速访问系统相关的外部服务,需要将 localhost 替换为实际的 IP 地址。
</p>
<div class="services-grid">
<div class="service-link-card">
<div class="service-info">
<h4>Neo4j 浏览器</h4>
<p>图数据库管理界面</p>
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:7474/')"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>API 接口文档</h4>
<p>系统接口文档和调试工具</p>
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:5050/docs')"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>MinIO 对象存储</h4>
<p>文件存储管理控制台</p>
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:9001')"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
</div>
<div class="service-link-card">
<div class="service-info">
<h4>Milvus WebUI</h4>
<p>向量数据库管理界面</p>
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:9091/webui/')"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, h } from 'vue'
import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user'
import { Globe } from 'lucide-vue-next'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
import RerankModelSelector from '@/components/RerankModelSelector.vue'
const configStore = useConfigStore()
const userStore = useUserStore()
const items = computed(() => configStore.config?._config_items || {})
const ocrEngineOptions = [
{ value: 'disable', label: '不启用' },
{ value: 'rapid_ocr', label: 'RapidOCR (ONNX)' },
{ value: 'mineru_ocr', label: 'MinerU OCR' },
{ value: 'mineru_official', label: 'MinerU Official API' },
{ value: 'pp_structure_v3_ocr', label: 'PP-Structure-V3' },
{ value: 'deepseek_ocr', label: 'DeepSeek OCR' },
{ value: 'paddleocr_vl_1_6', label: 'PaddleOCR-VL-1.6' },
{ value: 'paddleocr_pp_ocrv6', label: 'PP-OCRv6' }
]
const handleChange = (key, e) => {
configStore.setConfigValue(key, e)
}
const handleChatModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('default_model', spec)
}
}
const handleFastModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('fast_model', spec)
}
}
const handleContentGuardModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('content_guard_llm_model', spec)
}
}
const openLink = (url) => {
window.open(url, '_blank')
}
</script>
<style lang="less" scoped>
.basic-settings-section {
.section {
background-color: var(--gray-0);
padding: 10px 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--gray-150);
}
.settings-panel {
background-color: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.setting-row {
display: flex;
flex-direction: column;
gap: 8px;
&.two-cols {
flex-direction: row;
gap: 20px;
}
.col-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
}
.setting-label {
font-size: 13px;
font-weight: 500;
color: var(--gray-700);
}
.setting-content {
width: 100%;
.full-width {
width: 100%;
}
}
.card {
display: flex;
align-items: center;
justify-content: space-between;
.label {
margin-right: 20px;
font-weight: 500;
color: var(--gray-800);
flex-shrink: 0;
min-width: 140px;
}
&.card-select {
align-items: flex-start;
gap: 12px;
.label {
margin-right: 0;
margin-top: 6px;
}
}
}
.agent-select {
width: 320px;
max-width: 100%;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
margin-top: 16px;
}
.service-link-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
transition: all 0.2s;
min-height: 70px;
&:hover {
box-shadow: 0 1px 8px var(--gray-150);
border-color: var(--gray-100);
}
.service-info {
flex: 1;
margin-right: 16px;
h4 {
margin: 0 0 4px 0;
color: var(--gray-900);
font-size: 15px;
font-weight: 500;
}
p {
margin: 0;
color: var(--gray-600);
font-size: 13px;
line-height: 1.4;
}
}
}
@media (max-width: 768px) {
.agent-select {
width: 100%;
}
}
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="chunk-params-config">
<div class="params-info">
<p>调整分块参数可以控制文本的切分方式影响检索质量和文档加载效率</p>
</div>
<a-form :model="localParams" name="chunkConfig" autocomplete="off" layout="vertical">
<a-form-item v-if="showPreset" name="chunk_preset_id">
<template #label>
<span class="chunk-preset-label">
分块策略
<a-tooltip :title="presetDescription">
<QuestionCircleOutlined class="chunk-preset-help-icon" />
</a-tooltip>
</span>
</template>
<a-select
v-model:value="localParams.chunk_preset_id"
:options="presetOptions"
:loading="chunkPresetLoading"
style="width: 100%"
/>
<p class="param-description">
选择适合当前文档结构的分块策略
<span v-if="allowPresetFollowDefault">留空时沿用知识库默认策略</span>
</p>
</a-form-item>
<div class="chunk-row">
<a-form-item v-if="showChunkSizeOverlap" name="chunk_token_num">
<template #label>
<span class="chunk-preset-label">
最大 Token
<a-tooltip title="每个文本片段的最大 token 数,留空时使用默认值 512">
<QuestionCircleOutlined class="chunk-preset-help-icon" />
</a-tooltip>
</span>
</template>
<a-input-number
v-model:value="parserConfig.chunk_token_num"
:min="100"
:max="10000"
placeholder="默认 512"
style="width: 100%"
/>
</a-form-item>
<a-form-item v-if="showChunkSizeOverlap" name="overlapped_percent">
<template #label>
<span class="chunk-preset-label">
重叠比例 (%)
<a-tooltip title="相邻文本片段按 token 数计算的重叠比例,留空时使用默认值 0">
<QuestionCircleOutlined class="chunk-preset-help-icon" />
</a-tooltip>
</span>
</template>
<a-input-number
v-model:value="parserConfig.overlapped_percent"
:min="0"
:max="99"
placeholder="默认 0"
style="width: 100%"
/>
</a-form-item>
<a-form-item v-if="showQaSplit" name="delimiter">
<template #label>
<span class="chunk-preset-label">
分隔符
<a-tooltip title="支持 \\n、\\t 等转义字符。留空时使用默认分隔符 \\n">
<QuestionCircleOutlined class="chunk-preset-help-icon" />
</a-tooltip>
</span>
</template>
<a-input
v-model:value="parserConfig.delimiter"
placeholder="默认 \\n,可输入 \\n\\n\\n 或 ---"
style="width: 100%"
/>
</a-form-item>
</div>
</a-form>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { QuestionCircleOutlined } from '@ant-design/icons-vue'
import { useChunkPresetOptions } from '@/composables/useChunkPresetOptions'
import { DEFAULT_CHUNK_PRESET_ID, isPlainObject } from '@/utils/chunkUtils'
const props = defineProps({
tempChunkParams: {
type: Object,
required: true
},
showQaSplit: {
type: Boolean,
default: true
},
showChunkSizeOverlap: {
type: Boolean,
default: true
},
showPreset: {
type: Boolean,
default: true
},
allowPresetFollowDefault: {
type: Boolean,
default: false
},
databasePresetId: {
type: String,
default: DEFAULT_CHUNK_PRESET_ID
}
})
const localParams = computed(() => props.tempChunkParams)
const fallbackParserConfig = ref({})
const {
chunkPresetSelectOptions,
chunkPresetLabelMap,
chunkPresetLoading,
loadChunkPresetOptions,
getChunkPresetDescription
} = useChunkPresetOptions()
const parserConfig = computed(() => {
if (!isPlainObject(props.tempChunkParams.chunk_parser_config)) {
return fallbackParserConfig.value
}
return props.tempChunkParams.chunk_parser_config
})
const presetOptions = computed(() => {
const options = []
const defaultPresetLabel =
chunkPresetLabelMap.value[props.databasePresetId] ||
props.databasePresetId ||
DEFAULT_CHUNK_PRESET_ID
if (props.allowPresetFollowDefault) {
options.push({
value: '',
label: `沿用知识库默认(${defaultPresetLabel}`
})
}
options.push(...chunkPresetSelectOptions.value)
return options
})
const effectivePresetId = computed(
() => props.tempChunkParams.chunk_preset_id || props.databasePresetId || DEFAULT_CHUNK_PRESET_ID
)
const presetDescription = computed(() => getChunkPresetDescription(effectivePresetId.value))
onMounted(() => {
loadChunkPresetOptions()
})
</script>
<style scoped>
.chunk-params-config {
width: 100%;
}
.params-info {
margin-bottom: 16px;
}
.params-info p {
margin: 0;
color: var(--gray-500);
font-size: 14px;
line-height: 1.5;
}
.chunk-row {
display: flex;
gap: 16px;
margin-bottom: 8px;
}
.chunk-row > .ant-form-item {
flex: 1;
margin-bottom: 0;
}
.param-description {
font-size: 12px;
color: var(--gray-400);
margin: 4px 0 0 0;
line-height: 1.4;
}
.chunk-preset-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chunk-preset-help-icon {
color: var(--gray-500);
cursor: help;
font-size: 14px;
}
</style>
@@ -0,0 +1,353 @@
<template>
<section class="conversation-nav-section" :class="{ collapsed }">
<div v-if="showHistory && !collapsed" class="history-panel">
<div class="history-label" @click="listCollapsed = !listCollapsed">
<span>最近</span>
<ChevronDown :size="14" class="collapse-icon" :class="{ collapsed: listCollapsed }" />
</div>
<div v-show="!listCollapsed" class="conversation-list">
<template v-if="sortedChats.length > 0">
<div
v-for="chat in sortedChats"
:key="chat.id"
type="button"
class="conversation-item"
:class="{ active: currentChatId === chat.id }"
@click="$emit('select-chat', chat.id)"
@click.middle="$emit('delete-chat', chat.id)"
>
<span class="conversation-title">{{ chat.title || '新的对话' }}</span>
<span class="actions-mask"></span>
<span class="conversation-actions" @click.stop>
<a-dropdown :trigger="['click']">
<template #overlay>
<a-menu>
<a-menu-item
key="pin"
:icon="h(chat.is_pinned ? PinOff : Pin, { size: 14 })"
@click.stop="$emit('toggle-pin', chat.id)"
>
{{ chat.is_pinned ? '取消置顶' : '置顶' }}
</a-menu-item>
<a-menu-item
key="rename"
:icon="h(SquarePen, { size: 14 })"
@click.stop="renameChat(chat.id)"
>
重命名
</a-menu-item>
<a-menu-item
key="delete"
:icon="h(Trash2, { size: 14 })"
@click.stop="$emit('delete-chat', chat.id)"
>
删除
</a-menu-item>
</a-menu>
</template>
<span class="action-btn-wrapper">
<a-button type="text" class="more-btn">
<MoreVertical :size="16" />
</a-button>
<Pin v-if="chat.is_pinned" :size="14" class="pinned-indicator" />
</span>
</a-dropdown>
</span>
</div>
</template>
<div v-else-if="!collapsed" class="empty-list">暂无对话历史</div>
<div v-if="hasMoreChats && !collapsed" class="load-more-wrapper">
<a-button
type="text"
class="load-more-btn"
:loading="isLoadingMore"
@click="$emit('load-more-chats')"
>
{{ isLoadingMore ? '加载中...' : '加载更多' }}
</a-button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, h, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ChevronDown, MoreVertical, Pin, PinOff, SquarePen, Trash2 } from 'lucide-vue-next'
import { parseToShanghai } from '@/utils/time'
const props = defineProps({
currentChatId: {
type: String,
default: null
},
chatsList: {
type: Array,
default: () => []
},
hasMoreChats: {
type: Boolean,
default: false
},
isLoadingMore: {
type: Boolean,
default: false
},
collapsed: {
type: Boolean,
default: false
},
showHistory: {
type: Boolean,
default: true
}
})
const emit = defineEmits([
'select-chat',
'delete-chat',
'rename-chat',
'toggle-pin',
'load-more-chats'
])
const listCollapsed = ref(false)
const sortedChats = computed(() => {
return [...props.chatsList].sort((a, b) => {
if (a.is_pinned !== b.is_pinned) {
return a.is_pinned ? -1 : 1
}
const dateA = parseToShanghai(b.created_at)
const dateB = parseToShanghai(a.created_at)
if (!dateA || !dateB) return 0
return dateA.diff(dateB)
})
})
const renameChat = async (chatId) => {
const chat = props.chatsList.find((item) => item.id === chatId)
if (!chat) return
let newTitle = chat.title || ''
Modal.confirm({
title: '重命名对话',
content: h('div', { style: { marginTop: '12px' } }, [
h('input', {
value: newTitle,
style: {
width: '100%',
padding: '4px 8px',
border: '1px solid var(--gray-150)',
background: 'var(--gray-0)',
borderRadius: '4px'
},
onInput: (event) => {
newTitle = event.target.value
}
})
]),
okText: '确认',
cancelText: '取消',
onOk: () => {
if (!newTitle.trim()) {
message.warning('标题不能为空')
return Promise.reject()
}
emit('rename-chat', { chatId, title: newTitle })
}
})
}
</script>
<style lang="less" scoped>
.conversation-nav-section {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
margin-top: 8px;
overflow: hidden;
}
.conversation-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-panel {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.history-label {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 4px 8px;
color: var(--gray-800);
cursor: pointer;
font-size: 14px;
font-weight: 600;
border-radius: 4px;
transition: background-color 0.2s ease;
gap: 4px;
span {
font-weight: 500;
}
}
.collapse-icon {
transition: transform 0.2s ease;
&.collapsed {
transform: rotate(-90deg);
}
}
.conversation-list {
min-height: 0;
flex: 1;
overflow-y: auto;
padding-right: 2px;
scrollbar-width: thin;
}
.conversation-item {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 36px;
padding: 0 8px;
overflow: hidden;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--gray-700);
cursor: pointer;
font-size: 13px;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
&:hover {
background: var(--gray-50);
.actions-mask,
.conversation-actions {
opacity: 1;
}
.actions-mask {
background: linear-gradient(to right, transparent, var(--gray-50));
}
.more-btn {
display: inline-flex;
}
.pinned-indicator {
display: none;
}
}
&.active {
background-color: color-mix(in srgb, var(--main-color) 8%, var(--gray-0));
color: var(--main-color);
.conversation-title {
font-weight: 600;
}
.actions-mask {
opacity: 1;
background: linear-gradient(
to right,
transparent,
color-mix(in srgb, var(--main-color) 8%, var(--gray-0)) 20px
);
}
}
&:has(.pinned-indicator) {
.actions-mask,
.conversation-actions {
opacity: 1;
}
}
}
.actions-mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 56px;
background: linear-gradient(to right, transparent, var(--main-5) 20px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.conversation-actions {
position: absolute;
top: 50%;
right: 4px;
display: flex;
align-items: center;
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.2s ease;
}
.action-btn-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.more-btn {
position: absolute;
inset: 0;
z-index: 1;
display: none;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: var(--gray-600);
}
.pinned-indicator {
color: var(--gray-400);
}
.empty-list {
margin-top: 16px;
color: var(--gray-500);
font-size: 12px;
text-align: center;
}
.load-more-wrapper {
padding: 8px;
text-align: center;
}
.load-more-btn {
color: var(--main-color);
font-size: 12px;
}
</style>
@@ -0,0 +1,628 @@
<template>
<Teleport to="body">
<div v-if="open" class="conversation-search-overlay" @mousedown.self="close">
<section
class="conversation-search-modal"
role="dialog"
aria-modal="true"
aria-label="搜索对话"
@keydown.down.prevent="moveSelection(1)"
@keydown.up.prevent="moveSelection(-1)"
@keydown.enter.prevent="confirmSelection"
@keydown.esc.prevent="close"
>
<div class="conversation-search-input-row">
<input
ref="searchInputRef"
v-model="searchText"
class="conversation-search-input"
type="text"
placeholder="搜索对话..."
autocomplete="off"
aria-label="搜索对话"
/>
<button type="button" class="conversation-search-close" aria-label="关闭" @click="close">
<X :size="20" />
</button>
</div>
<div ref="resultListRef" class="conversation-search-body" @scroll="handleResultScroll">
<template v-if="isSearchMode">
<div v-if="isSearching && results.length === 0" class="conversation-search-skeleton">
<div v-for="index in 5" :key="index" class="skeleton-row">
<span class="skeleton-dot"></span>
<span class="skeleton-lines">
<i></i>
<i></i>
</span>
</div>
</div>
<div v-else-if="results.length > 0" class="conversation-search-results">
<button
v-for="(item, index) in results"
:key="item.id"
type="button"
class="conversation-search-result"
:class="{ selected: selectedIndex === index }"
@mouseenter="selectedIndex = index"
@click="selectSearchResult(item)"
>
<MessageCircle :size="18" class="result-icon" />
<span class="result-main">
<span class="result-title">{{ item.title || '新的对话' }}</span>
<span class="result-snippet">
<template v-for="(part, partIndex) in splitSnippet(item)" :key="partIndex">
<mark v-if="part.match">{{ part.text }}</mark>
<span v-else>{{ part.text }}</span>
</template>
</span>
</span>
<span class="result-date">{{
formatResultDate(item.latest_match_at || item.updated_at)
}}</span>
</button>
<div v-if="isLoadingMore" class="conversation-search-loading-more">加载中...</div>
</div>
<div v-else class="conversation-search-empty">未找到相关对话</div>
</template>
<template v-else>
<button
type="button"
class="conversation-search-default-item"
:class="{ selected: selectedIndex === 0 }"
@mouseenter="selectedIndex = 0"
@click="createThread"
>
<MessageCirclePlus :size="18" class="default-icon" />
<span>新对话</span>
</button>
<template v-for="row in recentRows" :key="row.key">
<div v-if="row.type === 'label'" class="conversation-search-group-label">
{{ row.label }}
</div>
<button
v-else
type="button"
class="conversation-search-default-item"
:class="{ selected: selectedIndex === row.actionIndex }"
@mouseenter="selectedIndex = row.actionIndex"
@click="selectRecentThread(row.thread)"
>
<MessageCircle :size="18" class="default-icon" />
<span>{{ row.thread.title || '新的对话' }}</span>
</button>
</template>
<div v-if="recentRows.length === 0" class="conversation-search-empty default-empty">
暂无对话历史
</div>
</template>
</div>
</section>
</div>
</Teleport>
</template>
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { MessageCircle, MessageCirclePlus, X } from 'lucide-vue-next'
import { threadApi } from '@/apis'
import dayjs, { parseToShanghai } from '@/utils/time'
const SEARCH_LIMIT = 20
const RECENT_LIMIT = 30
const props = defineProps({
open: {
type: Boolean,
default: false
},
recentThreads: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:open', 'select-thread', 'create-thread', 'thread-found'])
const searchInputRef = ref(null)
const resultListRef = ref(null)
const searchText = ref('')
const results = ref([])
const selectedIndex = ref(0)
const hasMore = ref(false)
const isSearching = ref(false)
const isLoadingMore = ref(false)
let searchTimer = null
let searchRequestId = 0
const trimmedSearchText = computed(() => searchText.value.trim())
const isSearchMode = computed(() => Boolean(trimmedSearchText.value))
const sortedRecentThreads = computed(() => {
return [...props.recentThreads]
.sort((a, b) => {
const first = parseToShanghai(a.updated_at || a.created_at)
const second = parseToShanghai(b.updated_at || b.created_at)
if (!first && !second) return 0
if (!first) return 1
if (!second) return -1
return second.valueOf() - first.valueOf()
})
.slice(0, RECENT_LIMIT)
})
const recentRows = computed(() => {
const rows = []
let lastGroup = ''
let actionIndex = 1
sortedRecentThreads.value.forEach((thread) => {
const group = getRecentGroupLabel(thread)
if (group !== lastGroup) {
rows.push({ type: 'label', key: `label-${group}`, label: group })
lastGroup = group
}
rows.push({
type: 'thread',
key: thread.id,
thread,
actionIndex
})
actionIndex += 1
})
return rows
})
const actionCount = computed(() => {
if (isSearchMode.value) return results.value.length
return 1 + sortedRecentThreads.value.length
})
const resetState = () => {
searchText.value = ''
results.value = []
hasMore.value = false
isSearching.value = false
isLoadingMore.value = false
selectedIndex.value = 0
}
const close = () => {
emit('update:open', false)
}
const createThread = () => {
emit('create-thread')
close()
}
const selectRecentThread = (thread) => {
if (!thread?.id) return
emit('select-thread', thread)
close()
}
const selectSearchResult = (item) => {
if (!item?.id) return
emit('thread-found', normalizeSearchThread(item))
emit('select-thread', normalizeSearchThread(item))
close()
}
const normalizeSearchThread = (item) => ({
id: item.id || item.thread_id,
uid: item.uid,
agent_id: item.agent_id,
title: item.title,
is_pinned: Boolean(item.is_pinned),
created_at: item.created_at,
updated_at: item.updated_at,
metadata: item.metadata || {}
})
const moveSelection = (delta) => {
if (actionCount.value <= 0) return
selectedIndex.value = (selectedIndex.value + delta + actionCount.value) % actionCount.value
scrollSelectedIntoView()
}
const confirmSelection = () => {
if (isSearchMode.value) {
const item = results.value[selectedIndex.value]
if (item) selectSearchResult(item)
return
}
if (selectedIndex.value === 0) {
createThread()
return
}
const thread = sortedRecentThreads.value[selectedIndex.value - 1]
if (thread) selectRecentThread(thread)
}
const scrollSelectedIntoView = () => {
nextTick(() => {
const selected = resultListRef.value?.querySelector('.selected')
selected?.scrollIntoView({ block: 'nearest' })
})
}
const searchThreads = async ({ reset = false } = {}) => {
const query = trimmedSearchText.value
if (!query) {
results.value = []
hasMore.value = false
selectedIndex.value = 0
return
}
const requestId = ++searchRequestId
const offset = reset ? 0 : results.value.length
if (reset) {
isSearching.value = true
hasMore.value = false
} else {
isLoadingMore.value = true
}
try {
const response = await threadApi.searchThreads(query, {
limit: SEARCH_LIMIT,
offset
})
if (requestId !== searchRequestId) return
const items = response?.items || []
results.value = reset ? items : [...results.value, ...items]
hasMore.value = Boolean(response?.has_more)
selectedIndex.value =
results.value.length > 0 ? Math.min(selectedIndex.value, results.value.length - 1) : 0
} catch (error) {
if (requestId === searchRequestId) {
console.warn('搜索对话失败:', error)
results.value = reset ? [] : results.value
hasMore.value = false
}
} finally {
if (requestId === searchRequestId) {
isSearching.value = false
isLoadingMore.value = false
}
}
}
const handleResultScroll = () => {
if (!isSearchMode.value || !hasMore.value || isSearching.value || isLoadingMore.value) return
const el = resultListRef.value
if (!el) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 80) {
searchThreads({ reset: false })
}
}
const getRecentGroupLabel = (thread) => {
const parsed = parseToShanghai(thread.updated_at || thread.created_at)
if (!parsed) return '更早'
const diffDays = dayjs().startOf('day').diff(parsed.startOf('day'), 'day')
if (diffDays <= 7) return '前 7 天'
if (diffDays <= 30) return '前 30 天'
return '更早'
}
const formatResultDate = (value) => {
const parsed = parseToShanghai(value)
if (!parsed) return ''
if (parsed.year() === dayjs().year()) return parsed.format('M月D日')
return parsed.format('YYYY-MM-DD')
}
const splitSnippet = (item) => {
const content = item?.snippets?.[0]?.content || ''
const query = trimmedSearchText.value
if (!content || !query) return [{ text: content, match: false }]
const lowerContent = content.toLowerCase()
const lowerQuery = query.toLowerCase()
const parts = []
let cursor = 0
let index = lowerContent.indexOf(lowerQuery)
while (index >= 0) {
if (index > cursor) {
parts.push({ text: content.slice(cursor, index), match: false })
}
parts.push({ text: content.slice(index, index + query.length), match: true })
cursor = index + query.length
index = lowerContent.indexOf(lowerQuery, cursor)
}
if (cursor < content.length) {
parts.push({ text: content.slice(cursor), match: false })
}
return parts
}
watch(
() => props.open,
(nextOpen) => {
if (!nextOpen) return
resetState()
nextTick(() => {
searchInputRef.value?.focus()
})
}
)
watch(trimmedSearchText, (query) => {
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
selectedIndex.value = 0
results.value = []
hasMore.value = false
if (!query) {
searchRequestId += 1
isSearching.value = false
return
}
isSearching.value = true
searchTimer = setTimeout(() => {
searchThreads({ reset: true })
}, 240)
})
onUnmounted(() => {
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
searchRequestId += 1
})
</script>
<style lang="less" scoped>
.conversation-search-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 18vh 16px 24px;
background: color-mix(in srgb, var(--gray-0) 72%, transparent);
backdrop-filter: blur(2px);
}
.conversation-search-modal {
width: min(680px, calc(100vw - 32px));
max-height: min(620px, 72vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--gray-150);
border-radius: 12px;
background: var(--gray-0);
box-shadow:
0 24px 60px var(--shadow-1),
0 2px 12px var(--shadow-0);
}
.conversation-search-input-row {
display: flex;
align-items: center;
min-height: 62px;
border-bottom: 1px solid var(--gray-100);
}
.conversation-search-input {
flex: 1 1 auto;
min-width: 0;
height: 62px;
padding: 0 18px;
border: 0;
outline: none;
background: transparent;
color: var(--gray-1000);
font-size: 18px;
line-height: 24px;
&::placeholder {
color: var(--gray-400);
}
}
.conversation-search-close {
flex: 0 0 40px;
width: 40px;
height: 40px;
margin-right: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--gray-500);
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
&:hover,
&:focus-visible {
background: var(--gray-50);
color: var(--gray-900);
outline: none;
}
}
.conversation-search-body {
min-height: 280px;
max-height: calc(72vh - 63px);
overflow-y: auto;
padding: 8px;
scrollbar-width: thin;
}
.conversation-search-default-item,
.conversation-search-result {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--gray-900);
cursor: pointer;
text-align: left;
transition:
background-color 0.18s ease,
border-color 0.18s ease;
&:hover,
&.selected,
&:focus-visible {
background: var(--gray-50);
outline: none;
}
}
.conversation-search-default-item {
height: 44px;
padding: 0 14px;
font-size: 15px;
}
.default-icon,
.result-icon {
flex: 0 0 18px;
color: var(--gray-700);
}
.conversation-search-group-label {
padding: 14px 14px 8px;
color: var(--gray-500);
font-size: 13px;
line-height: 18px;
}
.conversation-search-result {
min-height: 60px;
padding: 9px 12px;
}
.result-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.result-title {
overflow: hidden;
color: var(--gray-1000);
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-snippet {
overflow: hidden;
color: var(--gray-600);
font-size: 13px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
mark {
padding: 0;
background: color-mix(in srgb, var(--main-color) 14%, transparent);
color: var(--main-700);
}
}
.result-date {
flex: 0 0 auto;
align-self: center;
color: var(--gray-500);
font-size: 13px;
line-height: 18px;
}
.conversation-search-skeleton {
padding: 8px 14px;
}
.skeleton-row {
height: 58px;
display: flex;
align-items: center;
gap: 12px;
}
.skeleton-dot {
flex: 0 0 16px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--gray-100);
}
.skeleton-lines {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 10px;
i {
height: 8px;
border-radius: 999px;
background: var(--gray-100);
&:first-child {
width: 190px;
}
&:last-child {
width: min(390px, 72%);
}
}
}
.conversation-search-empty {
padding: 48px 16px;
color: var(--gray-500);
font-size: 14px;
text-align: center;
}
.default-empty {
padding-top: 32px;
}
.conversation-search-loading-more {
padding: 10px 0 6px;
color: var(--gray-500);
font-size: 13px;
text-align: center;
}
@media (max-width: 640px) {
.conversation-search-overlay {
padding-top: 12vh;
}
.conversation-search-input {
font-size: 16px;
}
}
</style>
+845
View File
@@ -0,0 +1,845 @@
<template>
<a-modal
v-model:open="showModal"
title="调试面板(请在生产环境中谨慎使用)"
width="90%"
:footer="null"
:maskClosable="true"
:destroyOnClose="true"
class="debug-modal"
>
<div :class="['log-viewer', { fullscreen: state.isFullscreen }]" ref="logViewer">
<div class="control-panel">
<div class="button-group">
<a-button
@click="fetchLogs"
:loading="state.fetching"
:icon="h(ReloadOutlined)"
class="icon-only"
>
</a-button>
<a-button @click="clearLogs" :icon="h(ClearOutlined)" class="icon-only"> </a-button>
<a-button @click="printSystemConfig">
<template #icon><SettingOutlined /></template>
系统配置
</a-button>
<a-button @click="printUserInfo">
<template #icon><UserOutlined /></template>
用户信息
</a-button>
<a-button @click="printDatabaseInfo">
<template #icon><DatabaseOutlined /></template>
知识库信息
</a-button>
<a-button @click="printAgentConfig">
<template #icon><RobotOutlined /></template>
智能体配置
</a-button>
<a-button @click="toggleDebugMode" :type="infoStore.debugMode ? 'primary' : 'default'">
<template #icon><BugOutlined /></template>
Debug 模式: {{ infoStore.debugMode ? '开启' : '关闭' }}
</a-button>
<a-button @click="toggleFullscreen">
<template #icon>
<FullscreenOutlined v-if="!state.isFullscreen" />
<FullscreenExitOutlined v-else />
</template>
{{ state.isFullscreen ? '退出全屏' : '全屏' }}
</a-button>
<a-tooltip :title="state.autoRefresh ? '点击停止自动刷新' : '点击开启自动刷新'">
<a-button
:type="state.autoRefresh ? 'primary' : 'default'"
:class="{ 'auto-refresh-button': state.autoRefresh }"
@click="toggleAutoRefresh(!state.autoRefresh)"
>
<template #icon>
<SyncOutlined :spin="state.autoRefresh" />
</template>
自动刷新
<span v-if="state.autoRefresh" class="refresh-interval">(5s)</span>
</a-button>
</a-tooltip>
<a-button @click="openUserSwitcher">
<template #icon><SwapOutlined /></template>
切换用户
</a-button>
</div>
<div class="filter-group">
<a-input-search
v-model:value="state.searchText"
placeholder="搜索日志..."
style="width: 200px; height: 32px"
@search="onSearch"
/>
<div class="log-level-selector">
<div class="multi-select-cards">
<div
v-for="level in logLevels"
:key="level.value"
class="option-card"
:class="{
selected: isLogLevelSelected(level.value),
unselected: !isLogLevelSelected(level.value)
}"
@click="toggleLogLevel(level.value)"
>
<div class="option-content">
<span class="option-text">{{ level.label }}</span>
<div class="option-indicator">
<CheckCircleOutlined v-if="isLogLevelSelected(level.value)" />
<PlusCircleOutlined v-else />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div ref="logContainer" class="log-container">
<div v-if="processedLogs.length" class="log-lines">
<div
v-for="(log, index) in processedLogs"
:key="index"
:class="['log-line', `level-${log.level.toLowerCase()}`]"
>
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="level">{{ log.level }}</span>
<span class="module">{{ log.module }}</span>
<span class="message">{{ log.message }}</span>
</div>
</div>
<div v-else class="empty-logs">暂无日志</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<!-- 用户切换 Modal -->
<a-modal
v-model:open="state.showUserSwitcher"
title="切换用户"
:confirmLoading="state.switchingUser"
:footer="null"
:bodyStyle="{ padding: '12px' }"
>
<a-list item-layout="horizontal" :data-source="state.users">
<template #renderItem="{ item }">
<a-list-item @click="switchToUser(item)" style="cursor: pointer">
<a-list-item-meta :title="item.username" :description="item.role" />
</a-list-item>
</template>
<template #empty>
<a-empty description="暂无用户" />
</template>
</a-list>
</a-modal>
</div>
</a-modal>
</template>
<script setup>
import {
ref,
reactive,
computed,
onMounted,
onActivated,
onUnmounted,
nextTick,
toRaw,
h,
watch
} from 'vue'
const showModal = defineModel('show')
// 监听 showModal 变化,当打开时获取日志
watch(showModal, (isOpen) => {
if (isOpen) {
// 延迟一下确保 DOM 渲染完成
setTimeout(fetchLogs, 100)
}
})
import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user'
import { useDatabaseStore } from '@/stores/database'
import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { useThrottleFn } from '@vueuse/core'
import {
message,
Modal,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Empty as AEmpty
} from 'ant-design-vue'
import {
FullscreenOutlined,
FullscreenExitOutlined,
ReloadOutlined,
ClearOutlined,
SettingOutlined,
SyncOutlined,
CheckCircleOutlined,
PlusCircleOutlined,
UserOutlined,
DatabaseOutlined,
RobotOutlined,
BugOutlined,
SwapOutlined
} from '@ant-design/icons-vue'
import dayjs from '@/utils/time'
import { configApi } from '@/apis/system_api'
import { checkSuperAdminPermission } from '@/stores/user'
const configStore = useConfigStore()
const userStore = useUserStore()
const databaseStore = useDatabaseStore()
const agentStore = useAgentStore()
const infoStore = useInfoStore()
const config = configStore.config
// 定义日志级别
const logLevels = [
{ value: 'INFO', label: 'INFO' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'WARNING', label: 'WARNING' }
]
const logViewer = ref(null)
// 状态管理
const state = reactive({
fetching: false,
autoRefresh: false,
searchText: '',
selectedLevels: logLevels.map((l) => l.value),
rawLogs: [],
isFullscreen: false,
showUserSwitcher: false,
users: [],
switchingUser: false
})
const error = ref('')
const logContainer = ref(null)
let autoRefreshInterval = null
// 解析日志行
const parseLogLine = (line) => {
// 支持两种时间戳格式:带毫秒和不带毫秒
const match = line.match(
/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:,\d{3})?)\s*-\s*(\w+)\s*-\s*([^-]+?)\s*-\s*(.+)$/
)
if (match) {
return {
timestamp: match[1],
level: match[2],
module: match[3].trim(),
message: match[4].trim(),
raw: line
}
}
return null
}
// 格式化时间戳
const formatTimestamp = (timestamp) => {
try {
// 处理带毫秒的格式:将 "2025-03-10 08:26:37,269" 转换为 "2025-03-10 08:26:37.269"
let normalizedTimestamp = timestamp.replace(',', '.')
// 如果没有毫秒,添加 .000
if (!/\.\d{3}$/.test(normalizedTimestamp)) {
normalizedTimestamp += '.000'
}
const date = dayjs(normalizedTimestamp)
return date.isValid() ? date.format('HH:mm:ss.SSS') : timestamp
} catch (err) {
console.error('时间戳格式化错误:', err)
return timestamp
}
}
// 处理日志显示
const processedLogs = computed(() => {
return state.rawLogs
.map(parseLogLine)
.filter((log) => log !== null)
.filter((log) => {
if (!state.searchText) return true
return log.raw.toLowerCase().includes(state.searchText.toLowerCase())
})
})
// 获取日志数据
const fetchLogs = async () => {
if (!checkSuperAdminPermission()) return
state.fetching = true
try {
error.value = ''
// 将选中的日志级别转换为逗号分隔的字符串传递给后端
const levelsParam = state.selectedLevels.join(',')
const logData = await configApi.getLogs(levelsParam)
state.rawLogs = logData.log.split('\n').filter((line) => line.trim())
await nextTick()
const scrollToBottom = useThrottleFn(() => {
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
}, 100)
scrollToBottom()
} catch (err) {
error.value = `错误: ${err.message}`
} finally {
state.fetching = false
}
}
// 清空日志
const clearLogs = () => {
if (!checkSuperAdminPermission()) return
state.rawLogs = []
}
// 搜索功能
const onSearch = () => {
// 搜索会通过computed自动触发
}
// 日志级别选择相关方法
const isLogLevelSelected = (level) => {
return state.selectedLevels.includes(level)
}
const toggleLogLevel = (level) => {
const currentLevels = [...state.selectedLevels]
const index = currentLevels.indexOf(level)
if (index > -1) {
// 如果取消选中后没有选中的级别,默认全选
if (currentLevels.length === 1) {
return
}
currentLevels.splice(index, 1)
} else {
currentLevels.push(level)
}
state.selectedLevels = currentLevels
// 切换日志级别后重新获取数据
fetchLogs()
}
// 自动刷新
const toggleAutoRefresh = (value) => {
if (!checkSuperAdminPermission()) return
if (value) {
autoRefreshInterval = setInterval(fetchLogs, 5000)
state.autoRefresh = true
} else {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval)
autoRefreshInterval = null
}
state.autoRefresh = false
}
}
// 全屏切换
const toggleFullscreen = async () => {
if (!checkSuperAdminPermission()) return
try {
if (!state.isFullscreen) {
if (logViewer.value.requestFullscreen) {
await logViewer.value.requestFullscreen()
} else if (logViewer.value.webkitRequestFullscreen) {
await logViewer.value.webkitRequestFullscreen()
} else if (logViewer.value.msRequestFullscreen) {
await logViewer.value.msRequestFullscreen()
}
} else {
if (document.exitFullscreen) {
await document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
await document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
await document.msExitFullscreen()
}
}
} catch (err) {
console.error('全屏切换失败:', err)
}
}
// 监听全屏变化
const handleFullscreenChange = () => {
state.isFullscreen = Boolean(
document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement
)
}
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
document.addEventListener('msfullscreenchange', handleFullscreenChange)
})
onActivated(() => {
if (state.autoRefresh) {
toggleAutoRefresh(true)
} else if (showModal.value) {
fetchLogs()
}
})
onUnmounted(() => {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval)
autoRefreshInterval = null
}
document.removeEventListener('fullscreenchange', handleFullscreenChange)
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
document.removeEventListener('msfullscreenchange', handleFullscreenChange)
})
// 打印系统配置
const printSystemConfig = () => {
if (!checkSuperAdminPermission()) return
console.log('=== 系统配置 ===')
console.log(config)
}
// 打印用户信息
const printUserInfo = () => {
if (!checkSuperAdminPermission()) return
console.log('=== 用户信息 ===')
const userInfo = {
token: userStore.token ? '*** (已隐藏)' : null,
userId: userStore.userId,
username: userStore.username,
uid: userStore.uid,
phoneNumber: userStore.phoneNumber,
avatar: userStore.avatar,
userRole: userStore.userRole,
isLoggedIn: userStore.isLoggedIn,
isAdmin: userStore.isAdmin,
isSuperAdmin: userStore.isSuperAdmin
}
console.log(JSON.stringify(userInfo, null, 2))
}
// 打印知识库信息
const printDatabaseInfo = async () => {
if (!checkSuperAdminPermission()) return
try {
console.log('=== 知识库信息 ===')
console.log('基本信息:', {
kbId: databaseStore.kbId,
databaseName: databaseStore.database.name,
databaseDesc: databaseStore.database.description,
fileCount: Object.keys(databaseStore.database.files || {}).length
})
console.log('状态信息:', {
databaseLoading: databaseStore.state.databaseLoading,
searchLoading: databaseStore.state.searchLoading,
lock: databaseStore.state.lock,
autoRefresh: databaseStore.state.autoRefresh,
queryParamsLoading: databaseStore.state.queryParamsLoading
})
console.log('查询参数:', {
queryParams: databaseStore.queryParams,
meta: databaseStore.meta,
selectedFileCount: databaseStore.selectedRowKeys.length
})
} catch (error) {
console.error('获取知识库信息失败:', error)
message.error('获取知识库信息失败: ' + error.message)
}
}
// 切换Debug模式
const toggleDebugMode = () => {
if (!checkSuperAdminPermission()) return
infoStore.toggleDebugMode()
}
// 打印智能体配置
const printAgentConfig = async () => {
if (!checkSuperAdminPermission()) return
try {
console.log('=== 智能体配置信息 ===')
// Store状态信息
console.log('Store 状态:', {
isInitialized: agentStore.isInitialized,
selectedAgentId: agentStore.selectedAgentId,
agentCount: agentStore.agentsList.length,
loadingStates: {
isLoadingAgents: agentStore.isLoadingAgents,
isLoadingConfig: agentStore.isLoadingConfig,
isLoadingTools: agentStore.isLoadingTools
},
error: agentStore.error,
hasConfigChanges: agentStore.hasConfigChanges
})
// 智能体列表信息
console.log('智能体列表:', {
count: agentStore.agentsList.length,
agents: toRaw(agentStore.agentsList)
})
// 当前选中智能体信息
if (agentStore.selectedAgent) {
console.log('当前选中智能体:', {
agent: toRaw(agentStore.selectedAgent),
isBuiltin: isBuiltinAgent(agentStore.selectedAgent),
configurableItemsCount: Object.keys(agentStore.configurableItems).length
})
// 当前智能体配置(仅管理员可见)
if (userStore.isAdmin) {
console.log('当前智能体配置:', {
current: toRaw(agentStore.agentConfig),
original: toRaw(agentStore.originalAgentConfig),
hasChanges: agentStore.hasConfigChanges
})
} else {
console.log('智能体配置: 需要管理员权限查看详细配置')
}
}
// 工具信息
const toolsList = agentStore.availableTools ? Object.values(agentStore.availableTools) : []
console.log('可用工具:', {
count: toolsList.length,
tools: toolsList
})
// 配置项信息(管理员可见)
if (userStore.isAdmin && agentStore.selectedAgent) {
console.log('可配置项:', toRaw(agentStore.configurableItems))
}
} catch (error) {
console.error('获取智能体配置失败:', error)
message.error('获取智能体配置失败: ' + error.message)
}
}
// 获取用户列表
const fetchUsers = async () => {
try {
const response = await fetch('/api/auth/users', {
headers: userStore.getAuthHeaders()
})
if (!response.ok) {
throw new Error('获取用户列表失败')
}
state.users = await response.json()
} catch (err) {
message.error(`获取用户列表失败: ${err.message}`)
}
}
// 打开用户选择器
const openUserSwitcher = () => {
if (!checkSuperAdminPermission()) return
state.showUserSwitcher = true
fetchUsers()
}
// 切换用户
const switchToUser = async (user) => {
if (!checkSuperAdminPermission()) return
// 危险操作确认
Modal.confirm({
title: '⚠️ 危险操作确认',
content: `确定要切换为用户 "${user.username}" 吗?此操作将被记录。`,
okText: '确认切换',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
state.switchingUser = true
try {
const response = await fetch(`/api/auth/impersonate/${user.id}`, {
method: 'POST',
headers: userStore.getAuthHeaders()
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '切换用户失败')
}
const data = await response.json()
// 设置新 token
localStorage.setItem('user_token', data.access_token)
message.success(`已切换用户: ${user.username}`)
state.showUserSwitcher = false
// 刷新页面以重新初始化应用
window.location.reload()
} catch (err) {
message.error(`切换失败: ${err.message}`)
} finally {
state.switchingUser = false
}
}
})
}
</script>
<style scoped>
.log-viewer.fullscreen {
padding: 16px;
}
.control-panel {
margin-bottom: 16px;
}
.button-group {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
.ant-btn {
min-width: 80px;
height: 32px;
padding: 4px 12px;
font-size: 13px;
border-color: var(--gray-300);
color: var(--gray-700);
&.icon-only {
min-width: 32px;
padding: 0;
}
&:hover {
border-color: var(--main-color);
color: var(--main-color);
}
&.ant-btn-primary {
background-color: var(--main-color);
border-color: var(--main-color);
color: var(--gray-0);
&:hover,
&:focus {
background-color: var(--main-color);
border-color: var(--main-color);
color: var(--gray-0);
}
}
.anticon {
font-size: 14px;
}
}
.refresh-interval {
font-size: 12px;
opacity: 0.8;
margin-left: 2px;
}
.auto-refresh-button {
color: var(--gray-0);
}
}
.filter-group {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
height: 32px;
@media (max-width: 768px) {
flex-direction: column;
gap: 12px;
}
}
.error {
color: var(--color-error-500);
}
.log-container {
height: calc(80vh - 200px);
overflow-y: auto;
background: #1e1f1f;
color: #ffffff;
border-radius: 5px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.log-lines {
padding: 8px;
}
.log-line {
padding: 2px 4px;
display: flex;
gap: 8px;
line-height: 1.4;
}
.log-line:hover {
background: rgba(255, 255, 255, 0.05);
}
.timestamp {
color: var(--color-success-500);
min-width: 80px;
}
.level {
min-width: 40px;
font-weight: bold;
}
.module {
color: var(--color-info-500);
min-width: 30px;
}
.message {
flex: 1;
white-space: pre-wrap;
word-break: break-all;
}
.level-info {
.level {
color: var(--color-success-500);
}
}
.level-error {
.level {
color: var(--color-error-500);
}
}
.level-debug {
.level {
color: var(--color-info-500);
}
}
.level-warning {
.level {
color: var(--color-warning-500);
}
}
.empty-logs {
padding: 16px;
text-align: center;
color: var(--gray-500);
}
:fullscreen .log-container {
height: calc(100vh - 160px);
}
:-webkit-full-screen .log-container {
height: calc(100vh - 160px);
}
:-ms-fullscreen .log-container {
height: calc(100vh - 160px);
}
.multi-select-cards {
display: flex;
flex-direction: row;
gap: 10px;
.option-card {
border: 1px solid var(--gray-300);
border-radius: 6px;
padding: 0px 10px;
cursor: pointer;
transition: all 0.2s ease;
background: var(--gray-0);
user-select: none;
height: 32px;
display: flex;
align-items: center;
&:hover {
border-color: var(--main-color);
background: var(--main-5);
}
&.selected {
border-color: var(--main-color);
background: var(--main-10);
.option-indicator {
color: var(--main-color);
}
.option-text {
color: var(--main-color);
font-weight: 500;
}
}
&.unselected {
.option-indicator {
color: var(--gray-400);
}
.option-text {
color: var(--gray-700);
}
}
.option-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
width: 100%;
}
.option-text {
flex: 1;
font-size: 12px;
text-align: center;
}
.option-indicator {
flex-shrink: 0;
font-size: 14px;
transition: color 0.2s ease;
}
}
}
/* 响应式适配 */
@media (max-width: 768px) {
.log-level-selector {
min-width: 280px;
}
.multi-select-cards .options-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -0,0 +1,634 @@
<template>
<div class="department-management">
<!-- 头部区域 -->
<div class="header-section">
<div class="header-content">
<div class="section-title">部门管理</div>
<p class="section-description">管理系统部门部门下的用户会被隔离管理</p>
</div>
<div class="header-actions">
<a-button
@click="handleRefresh"
:loading="departmentManagement.refreshing"
title="刷新"
class="refresh-btn lucide-icon-btn"
>
<template #icon
><RefreshCw :size="16" :class="{ spin: departmentManagement.refreshing }"
/></template>
</a-button>
<a-button type="primary" @click="showAddDepartmentModal" class="add-btn lucide-icon-btn">
<template #icon><Plus :size="16" /></template>
添加部门
</a-button>
</div>
</div>
<!-- 主内容区域 -->
<div class="content-section">
<a-spin :spinning="departmentManagement.loading">
<div v-if="departmentManagement.error" class="error-message">
<a-alert type="error" :message="departmentManagement.error" show-icon />
</div>
<template v-if="departmentManagement.departments.length > 0">
<a-table
:dataSource="departmentManagement.departments"
:columns="columns"
:rowKey="(record) => record.id"
:pagination="false"
class="department-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="department-name">
<span class="name-text">{{ record.name }}</span>
</div>
</template>
<template v-if="column.key === 'description'">
<span class="description-text">{{ record.description || '-' }}</span>
</template>
<template v-if="column.key === 'userCount'">
<span>{{ record.user_count ?? 0 }} </span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-tooltip title="编辑部门">
<a-button
type="text"
size="small"
@click="showEditDepartmentModal(record)"
class="action-btn lucide-icon-btn"
>
<SquarePen :size="14" />
</a-button>
</a-tooltip>
<a-tooltip title="删除部门">
<a-button
type="text"
size="small"
danger
@click="confirmDeleteDepartment(record)"
:disabled="record.id === 1"
class="action-btn lucide-icon-btn"
>
<Trash2 :size="14" />
</a-button>
</a-tooltip>
</a-space>
</template>
</template>
</a-table>
</template>
<div v-else class="empty-state">
<a-empty description="暂无部门数据" />
</div>
</a-spin>
</div>
<!-- 部门表单模态框 -->
<a-modal
v-model:open="departmentManagement.modalVisible"
:title="departmentManagement.modalTitle"
@ok="handleDepartmentFormSubmit"
:confirmLoading="departmentManagement.loading"
@cancel="departmentManagement.modalVisible = false"
:maskClosable="false"
width="520px"
class="department-modal"
>
<a-form layout="vertical" class="department-form">
<a-form-item label="部门名称" required class="form-item">
<a-input
v-model:value="departmentManagement.form.name"
placeholder="请输入部门名称"
size="large"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="部门描述" class="form-item">
<a-textarea
v-model:value="departmentManagement.form.description"
placeholder="请输入部门描述(可选)"
:rows="3"
:maxlength="255"
show-count
/>
</a-form-item>
<a-divider v-if="!departmentManagement.editMode" />
<template v-if="!departmentManagement.editMode">
<p class="admin-section-hint">
创建部门时必须同时创建管理员该管理员将负责管理本部门用户
</p>
<a-form-item label="管理员UID" required class="form-item">
<a-input
v-model:value="departmentManagement.form.adminUid"
placeholder="请输入管理员UID3-20位字母/数字/下划线)"
size="large"
:maxlength="20"
@blur="checkAdminUid"
/>
<div v-if="departmentManagement.form.uidError" class="error-text">
{{ departmentManagement.form.uidError }}
</div>
<div v-else class="help-text"> UID 将用于登录</div>
</a-form-item>
<a-form-item label="密码" required class="form-item">
<a-input-password
v-model:value="departmentManagement.form.adminPassword"
placeholder="请输入管理员密码"
size="large"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="确认密码" required class="form-item">
<a-input-password
v-model:value="departmentManagement.form.adminConfirmPassword"
placeholder="请再次输入密码"
size="large"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="手机号(可选)" class="form-item">
<a-input
v-model:value="departmentManagement.form.adminPhone"
placeholder="请输入手机号(可用于登录)"
size="large"
:maxlength="11"
/>
<div v-if="departmentManagement.form.phoneError" class="error-text">
{{ departmentManagement.form.phoneError }}
</div>
</a-form-item>
</template>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { reactive, onMounted, watch } from 'vue'
import { notification, message, Modal } from 'ant-design-vue'
import { departmentApi, apiSuperAdminGet } from '@/apis'
import { Plus, RefreshCw, SquarePen, Trash2 } from 'lucide-vue-next'
// 表格列定义
const columns = [
{
title: '部门名称',
dataIndex: 'name',
key: 'name',
width: 200
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true
},
{
title: '用户数量',
dataIndex: 'user_count',
key: 'userCount',
width: 100,
align: 'center'
},
{
title: '操作',
key: 'action',
width: 120,
align: 'center'
}
]
// 部门管理状态
const departmentManagement = reactive({
loading: false,
refreshing: false,
departments: [],
error: null,
modalVisible: false,
modalTitle: '添加部门',
editMode: false,
editDepartmentId: null,
form: {
name: '',
description: '',
adminUid: '',
adminPassword: '',
adminConfirmPassword: '',
adminPhone: '',
uidError: '',
phoneError: ''
}
})
// 获取部门列表
const fetchDepartments = async () => {
try {
departmentManagement.loading = true
departmentManagement.error = null
const departments = await departmentApi.getDepartments()
departmentManagement.departments = departments
} catch (error) {
console.error('获取部门列表失败:', error)
departmentManagement.error = '获取部门列表失败'
} finally {
departmentManagement.loading = false
}
}
// 刷新部门列表
const handleRefresh = async () => {
if (departmentManagement.refreshing) return
departmentManagement.refreshing = true
try {
await fetchDepartments()
message.success('刷新成功')
} catch (error) {
console.error('刷新失败:', error)
message.error('刷新失败')
} finally {
departmentManagement.refreshing = false
}
}
// 打开添加部门模态框
const showAddDepartmentModal = () => {
departmentManagement.modalTitle = '添加部门'
departmentManagement.editMode = false
departmentManagement.editDepartmentId = null
departmentManagement.form = {
name: '',
description: '',
adminUid: '',
adminPassword: '',
adminConfirmPassword: '',
adminPhone: '',
uidError: '',
phoneError: ''
}
departmentManagement.modalVisible = true
}
// 打开编辑部门模态框
const showEditDepartmentModal = (department) => {
departmentManagement.modalTitle = '编辑部门'
departmentManagement.editMode = true
departmentManagement.editDepartmentId = department.id
departmentManagement.form = {
name: department.name,
description: department.description || '',
adminUid: '',
adminPassword: '',
adminConfirmPassword: '',
adminPhone: '',
uidError: '',
phoneError: ''
}
departmentManagement.modalVisible = true
}
// 验证手机号格式
const validatePhoneNumber = (phone) => {
if (!phone) {
return true // 手机号可选
}
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
// 监听手机号输入变化
watch(
() => departmentManagement.form.adminPhone,
(newPhone) => {
departmentManagement.form.phoneError = ''
if (newPhone && !validatePhoneNumber(newPhone)) {
departmentManagement.form.phoneError = '请输入正确的手机号格式'
}
}
)
// 检查管理员UID是否可用
const checkAdminUid = async () => {
const uid = departmentManagement.form.adminUid.trim()
departmentManagement.form.uidError = ''
if (!uid) {
return
}
// 验证格式
if (!/^[a-zA-Z0-9_]+$/.test(uid)) {
departmentManagement.form.uidError = 'UID只能包含字母、数字和下划线'
return
}
if (uid.length < 3 || uid.length > 20) {
departmentManagement.form.uidError = 'UID长度必须在3-20个字符之间'
return
}
// 检查是否已存在
try {
const result = await apiSuperAdminGet(`/api/auth/check-uid/${uid}`)
if (!result.is_available) {
departmentManagement.form.uidError = '该UID已被使用'
}
} catch (error) {
console.error('检查UID失败:', error)
}
}
// 处理部门表单提交
const handleDepartmentFormSubmit = async () => {
try {
// 验证部门名称
if (!departmentManagement.form.name.trim()) {
notification.error({ message: '部门名称不能为空' })
return
}
if (departmentManagement.form.name.trim().length < 2) {
notification.error({ message: '部门名称至少2个字符' })
return
}
// 验证管理员UID
const adminUid = departmentManagement.form.adminUid.trim()
if (!adminUid) {
notification.error({ message: '请输入管理员UID' })
return
}
if (!/^[a-zA-Z0-9_]+$/.test(adminUid)) {
notification.error({ message: 'UID只能包含字母、数字和下划线' })
return
}
if (adminUid.length < 3 || adminUid.length > 20) {
notification.error({ message: 'UID长度必须在3-20个字符之间' })
return
}
if (departmentManagement.form.uidError) {
notification.error({ message: '管理员UID已存在或格式错误' })
return
}
// 验证密码
if (!departmentManagement.form.adminPassword) {
notification.error({ message: '请输入管理员密码' })
return
}
if (
departmentManagement.form.adminPassword !== departmentManagement.form.adminConfirmPassword
) {
notification.error({ message: '两次输入的密码不一致' })
return
}
// 验证手机号
if (
departmentManagement.form.adminPhone &&
!validatePhoneNumber(departmentManagement.form.adminPhone)
) {
notification.error({ message: '请输入正确的手机号格式' })
return
}
departmentManagement.loading = true
if (departmentManagement.editMode) {
// 更新部门
await departmentApi.updateDepartment(departmentManagement.editDepartmentId, {
name: departmentManagement.form.name.trim(),
description: departmentManagement.form.description.trim() || undefined
})
notification.success({ message: '部门更新成功' })
} else {
// 创建部门,同时创建管理员
await departmentApi.createDepartment({
name: departmentManagement.form.name.trim(),
description: departmentManagement.form.description.trim() || undefined,
admin_uid: adminUid,
admin_password: departmentManagement.form.adminPassword,
admin_phone: departmentManagement.form.adminPhone || undefined
})
message.success(`部门创建成功,管理员 "${adminUid}" 已创建`)
}
// 重新获取部门列表
await fetchDepartments()
departmentManagement.modalVisible = false
} catch (error) {
console.error('部门操作失败:', error)
notification.error({
message: '操作失败',
description: error.message || '请稍后重试'
})
} finally {
departmentManagement.loading = false
}
}
// 删除部门
const confirmDeleteDepartment = (department) => {
Modal.confirm({
title: '确认删除部门',
content: `确定要删除部门 "${department.name}" 吗?此操作不可撤销。该部门下的用户会被迁移到默认部门,部门级配置和部门 API Key 会一并清理。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
departmentManagement.loading = true
await departmentApi.deleteDepartment(department.id)
notification.success({ message: '部门删除成功' })
// 重新获取部门列表
await fetchDepartments()
} catch (error) {
console.error('删除部门失败:', error)
notification.error({
message: '删除失败',
description: error.message || '请稍后重试'
})
} finally {
departmentManagement.loading = false
}
}
})
}
// 在组件挂载时获取部门列表
onMounted(() => {
fetchDepartments()
})
</script>
<style lang="less" scoped>
.department-management {
.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 12px;
}
.section-description {
font-size: 14px;
color: var(--gray-600);
line-height: 1.4;
margin: 0;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
background: var(--gray-25);
}
.spin {
animation: spin 1s linear infinite;
}
}
}
}
.content-section {
overflow: hidden;
.error-message {
padding: 16px 24px;
}
.empty-state {
padding: 60px 20px;
text-align: center;
}
.department-table {
:deep(.ant-table-thead > tr > th) {
background: var(--gray-50);
font-weight: 500;
padding: 8px 12px;
}
:deep(.ant-table-tbody > tr > td) {
padding: 8px 12px;
}
.department-name {
.name-text {
font-weight: 500;
color: var(--gray-900);
}
}
.description-text {
color: var(--gray-600);
}
.action-btn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
background: var(--gray-25);
}
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.department-modal {
:deep(.ant-modal-header) {
padding: 20px 24px;
border-bottom: 1px solid var(--gray-150);
.ant-modal-title {
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
}
}
:deep(.ant-modal-body) {
padding: 24px;
}
.department-form {
.form-item {
margin-bottom: 20px;
:deep(.ant-form-item-label) {
padding-bottom: 4px;
label {
font-weight: 500;
color: var(--gray-900);
}
}
}
}
.error-text {
color: var(--color-error-500);
font-size: 12px;
margin-top: 4px;
line-height: 1.3;
}
.help-text {
color: var(--gray-600);
font-size: 12px;
margin-top: 4px;
line-height: 1.3;
}
}
</style>
@@ -0,0 +1,161 @@
<template>
<a-dropdown trigger="click" @open-change="handleOpenChange">
<div class="model-select" :class="modelSelectClasses" @click.prevent>
<div class="model-select-content">
<div class="model-info">
<a-tooltip :title="displayText" placement="right">
<span class="model-text">{{ displayText }}</span>
</a-tooltip>
</div>
</div>
</div>
<template #overlay>
<a-menu class="scrollable-menu">
<a-menu-item-group v-for="(providerData, providerId) in v2Models" :key="providerId">
<template #title>
<span>{{ providerId }}</span>
</template>
<a-menu-item
v-for="model in providerData.models"
:key="model.spec"
@click="handleSelect(model.spec)"
>
<div class="model-option">
<span class="model-option-name">
{{ model.display_name }}
<span v-if="model.dimension" class="model-dimension">({{ model.dimension }})</span>
</span>
<span
class="model-status-icon"
:class="getStatusClass(model.spec)"
:title="getStatusTooltip(model.spec)"
>{{ getStatusIcon(model.spec) }}</span
>
</div>
</a-menu-item>
</a-menu-item-group>
</a-menu>
</template>
</a-dropdown>
</template>
<script setup>
import { computed, ref } from 'vue'
import { modelProviderApi } from '@/apis/system_api'
import { useModelStatus } from '@/composables/useModelStatus'
const props = defineProps({
value: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请选择嵌入模型'
},
size: {
type: String,
default: 'small',
validator: (value) => ['default', 'small', 'middle', 'large'].includes(value)
},
style: {
type: Object,
default: () => ({ width: '100%' })
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:value', 'change'])
const v2Models = ref({})
const { getStatusIcon, getStatusClass, getStatusTooltip, checkV2Statuses } = useModelStatus()
const displayText = computed(() => props.value || props.placeholder)
const resolvedSize = computed(() => props.size || 'small')
const modelSelectClasses = computed(() => ({
'model-select--middle': resolvedSize.value === 'middle',
'model-select--large': resolvedSize.value === 'large'
}))
const handleOpenChange = async (open) => {
if (!open) return
await fetchV2Models()
}
const fetchV2Models = async () => {
try {
const response = await modelProviderApi.getV2Models('embedding')
if (response.success) {
v2Models.value = response.data || {}
await checkV2ModelStatuses()
}
} catch (error) {
console.error('获取 embedding 模型失败:', error)
}
}
const checkV2ModelStatuses = async () => {
try {
const models = Object.values(v2Models.value).flatMap(
(providerData) => providerData.models || []
)
await checkV2Statuses(models)
} catch (error) {
console.error('检查 embedding 模型状态失败:', error)
}
}
const handleSelect = (value) => {
emit('update:value', value)
emit('change', value)
}
</script>
<style lang="less" scoped>
@import '@/assets/css/model-selector-common.less';
.model-option {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.model-option-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-dimension {
color: var(--gray-500);
font-size: 12px;
margin-left: 4px;
}
.model-status-icon {
font-size: 11px;
font-weight: bold;
flex-shrink: 0;
color: var(--gray-500);
&.available {
color: var(--color-success-500);
}
&.unavailable {
color: var(--color-error-500);
}
&.error {
color: var(--color-warning-500);
}
}
</style>
File diff suppressed because it is too large Load Diff
+880
View File
@@ -0,0 +1,880 @@
<template>
<a-modal
v-model:open="visible"
width="800px"
:footer="null"
:closable="false"
wrap-class-name="file-detail"
@after-open-change="afterOpenChange"
:bodyStyle="{ height: '80vh', padding: '0' }"
>
<template #title>
<div class="modal-title-wrapper">
<!-- 左侧文件名和图标 -->
<div class="file-title">
<FileTypeIcon :name="file?.filename" :size="18" />
<span class="file-name">{{ file?.filename || '文件详情' }}</span>
</div>
<div class="header-controls">
<!-- 字符数/片段数显示在 segment 左边 -->
<span v-if="viewInfoText" class="view-info">{{ viewInfoText }}</span>
<!-- 视图模式切换 -->
<div class="view-controls" v-if="file && viewModeOptions.length > 1">
<a-segmented v-model:value="viewMode" :options="viewModeOptions" />
</div>
<!-- 下载按钮下拉菜单 -->
<a-dropdown trigger="click" v-if="file">
<a-button type="default" class="download-btn" title="下载" aria-label="下载">
<Download :size="16" />
<ChevronDown :size="14" />
</a-button>
<template #overlay>
<a-menu @click="handleDownloadMenuClick">
<a-menu-item key="original" :disabled="!file.file_id">
<template #icon><Download :size="16" /></template>
下载原文
</a-menu-item>
<a-menu-item key="markdown" :disabled="contentState.loading || !mergedContent">
<template #icon><FileText :size="16" /></template>
下载 Markdown
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- 自定义关闭按钮 -->
<button class="custom-close-btn" @click="visible = false">
<X :size="16" />
</button>
</div>
</div>
</template>
<div v-if="basicLoading" class="loading-container">
<a-spin tip="正在加载文档内容..." />
</div>
<div v-else-if="detailError" class="empty-content">
<p>{{ detailError }}</p>
</div>
<div v-else-if="file && hasAvailableView" class="file-detail-content">
<div v-if="viewMode === 'source'" class="content-panel source-panel">
<div v-if="sourcePreview.loading" class="loading-container">
<a-spin tip="正在加载源文件预览..." />
</div>
<AgentFilePreview
v-else
:file="sourcePreviewFile"
:file-path="file?.filename || ''"
:show-header="false"
:show-download="false"
:show-inline-html-controls="true"
:full-height="true"
:borderless="true"
container-class="source-preview-container"
content-class="source-preview-content"
/>
</div>
<!-- Markdown 模式 -->
<div v-else-if="viewMode === 'markdown'" class="content-panel flat-md-preview">
<div v-if="contentState.loading" class="loading-container">
<a-spin tip="正在加载解析内容..." />
</div>
<MarkdownPreview
v-else-if="mergedContent"
:content="mergedContent"
class="markdown-content"
/>
<div v-else class="empty-content">
<p>{{ contentState.error || '暂无文件内容' }}</p>
</div>
</div>
<!-- Chunks 模式使用 Grid 布局 -->
<div v-else-if="viewMode === 'chunks'" class="chunks-panel">
<div v-if="contentState.loading" class="loading-container">
<a-spin tip="正在加载分块内容..." />
</div>
<div v-else class="chunk-grid">
<div v-for="chunk in mappedChunks" :key="chunk.id" class="chunk-card">
<div class="chunk-card-header">
<span class="chunk-order">#{{ chunk.chunk_order_index }}</span>
</div>
<div class="chunk-card-content">
{{ chunk.content.replace(/\n+/g, ' ') }}
</div>
</div>
</div>
<div v-if="!contentState.loading && mappedChunks.length === 0" class="empty-content">
<p>{{ contentState.error || '暂无分块信息' }}</p>
</div>
</div>
</div>
<div v-else-if="file" class="empty-content">
<p>暂无文件内容</p>
</div>
</a-modal>
</template>
<script setup>
import { computed, h, onBeforeUnmount, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { documentApi } from '@/apis/knowledge_api'
import { getWorkspaceKnowledgeFileContent } from '@/apis/workspace_api'
import { mergeChunks } from '@/utils/chunkUtils'
import { getPreviewTypeByPath, normalizePreviewResponse } from '@/utils/file_preview'
import {
canPreviewChunks,
canPreviewOriginal,
canPreviewParsed,
getDefaultDetailView
} from '@/utils/knowledge_file_policy'
import MarkdownPreview from '@/components/common/MarkdownPreview.vue'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import { Download, ChevronDown, FileSearch, FileText, Rows3, X } from 'lucide-vue-next'
const props = defineProps({
open: {
type: Boolean,
default: false
},
kbId: {
type: [String, Number],
default: ''
},
fileId: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['update:open', 'closed'])
const visible = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const file = ref(null)
const basicLoading = ref(false)
const detailError = ref('')
const downloadingOriginal = ref(false)
const downloadingMarkdown = ref(false)
const contentState = ref({
loading: false,
loaded: false,
lines: [],
content: '',
error: ''
})
const sourcePreview = ref({
loading: false,
url: '',
content: '',
type: '',
message: '',
supported: true
})
let basicRequestSeq = 0
let contentRequestSeq = 0
let sourceRequestSeq = 0
const revokeSourcePreviewUrl = () => {
if (sourcePreview.value.url) {
window.URL.revokeObjectURL(sourcePreview.value.url)
sourcePreview.value.url = ''
}
}
const resetContentState = () => {
contentState.value = {
loading: false,
loaded: false,
lines: [],
content: '',
error: ''
}
}
const resetSourcePreview = () => {
sourceRequestSeq += 1
revokeSourcePreviewUrl()
sourcePreview.value = {
loading: false,
url: '',
content: '',
type: '',
message: '',
supported: true
}
}
const resetLocalState = () => {
basicRequestSeq += 1
contentRequestSeq += 1
file.value = null
basicLoading.value = false
detailError.value = ''
downloadingOriginal.value = false
downloadingMarkdown.value = false
resetContentState()
resetSourcePreview()
viewMode.value = 'markdown'
}
const normalizeFileMeta = (meta = {}) => ({
...meta,
file_id: meta.file_id || String(props.fileId || ''),
kb_id: meta.kb_id || String(props.kbId || ''),
filename: meta.filename || meta.original_filename || String(props.fileId || ''),
file_size: meta.file_size ?? meta.size ?? 0,
has_original_file:
'has_original_file' in meta
? Boolean(meta.has_original_file)
: Boolean(meta.minio_url || meta.path),
has_parsed_markdown:
'has_parsed_markdown' in meta ? Boolean(meta.has_parsed_markdown) : Boolean(meta.markdown_file)
})
const ensureApiSuccess = (data, fallbackMessage) => {
if (data?.status === 'failed') {
throw new Error(data.message || fallbackMessage)
}
}
// 视图模式
const viewMode = ref('markdown')
const hasContent = computed(
() =>
(contentState.value.lines && contentState.value.lines.length > 0) || contentState.value.content
)
const sourcePreviewCandidateType = computed(() => getPreviewTypeByPath(file.value?.filename || ''))
const sourcePreviewDisplayType = computed(
() => sourcePreview.value.type || sourcePreviewCandidateType.value
)
const sourceContentLength = computed(() =>
typeof sourcePreview.value.content === 'string' ? sourcePreview.value.content.length : 0
)
const sourcePreviewFile = computed(() => {
if (!file.value) return null
return {
...file.value,
content: sourcePreview.value.content,
previewType: sourcePreviewDisplayType.value,
previewUrl: sourcePreview.value.url,
supported: sourcePreview.value.supported,
message: sourcePreview.value.message
}
})
const hasSourcePreview = computed(() => canPreviewOriginal(file.value))
const hasMarkdownPreview = computed(() => canPreviewParsed(file.value) || hasContent.value)
const hasChunkPreview = computed(() => canPreviewChunks(file.value))
const availableViewModes = computed(() => {
const modes = []
if (hasSourcePreview.value) modes.push('source')
if (hasMarkdownPreview.value) modes.push('markdown')
if (hasChunkPreview.value) modes.push('chunks')
return modes
})
const hasAvailableView = computed(() => availableViewModes.value.length > 0)
const makeViewModeOption = (label, value, icon) => ({
label: h(
'span',
{
class: 'view-option-icon',
title: label,
'aria-label': label
},
[h(icon, { size: 15 })]
),
value
})
const viewModeOptions = computed(() => {
const optionMap = {
source: makeViewModeOption('源文件', 'source', FileSearch),
markdown: makeViewModeOption('Markdown', 'markdown', FileText),
chunks: makeViewModeOption('Chunks', 'chunks', Rows3)
}
return availableViewModes.value.map((mode) => optionMap[mode])
})
const loadBasicInfo = async () => {
const kbId = String(props.kbId || '')
const fileId = String(props.fileId || '')
if (!kbId || !fileId) return
const requestId = ++basicRequestSeq
contentRequestSeq += 1
file.value = null
detailError.value = ''
resetContentState()
resetSourcePreview()
viewMode.value = 'markdown'
basicLoading.value = true
try {
const data = await documentApi.getDocumentBasicInfo(kbId, fileId)
if (requestId !== basicRequestSeq) return
ensureApiSuccess(data, '加载文件信息失败')
const nextFile = normalizeFileMeta(data?.meta || data)
if (nextFile.is_folder) {
detailError.value = '文件夹不支持详情预览'
return
}
file.value = nextFile
viewMode.value = getDefaultDetailView(nextFile)
} catch (error) {
if (requestId !== basicRequestSeq) return
console.error('加载文件基本信息失败:', error)
detailError.value = error.message || '加载文件信息失败'
message.error(detailError.value)
} finally {
if (requestId === basicRequestSeq) {
basicLoading.value = false
}
}
}
const loadParsedContent = async () => {
if (!props.kbId || !props.fileId || contentState.value.loading || contentState.value.loaded)
return
const requestId = ++contentRequestSeq
contentState.value = {
...contentState.value,
loading: true,
error: ''
}
try {
const data = await documentApi.getDocumentContent(props.kbId, props.fileId)
if (requestId !== contentRequestSeq) return
ensureApiSuccess(data, '加载解析内容失败')
contentState.value = {
loading: false,
loaded: true,
lines: data?.lines || [],
content: data?.content || '',
error: ''
}
} catch (error) {
if (requestId !== contentRequestSeq) return
console.error('加载解析内容失败:', error)
const errorMessage = error.message || '加载解析内容失败'
contentState.value = {
loading: false,
loaded: false,
lines: [],
content: '',
error: errorMessage
}
message.error(errorMessage)
}
}
watch(
() => [props.open, props.kbId, props.fileId],
([open]) => {
if (!open) {
resetLocalState()
return
}
loadBasicInfo()
},
{ immediate: true }
)
watch(
availableViewModes,
(modes) => {
if (modes.length > 0 && !modes.includes(viewMode.value)) {
viewMode.value = modes[0]
}
},
{ immediate: true }
)
watch(
[visible, file, viewMode],
async ([open, currentFile, currentViewMode]) => {
if (!open || !currentFile) return
if (
(currentViewMode === 'markdown' && canPreviewParsed(currentFile)) ||
(currentViewMode === 'chunks' && canPreviewChunks(currentFile))
) {
await loadParsedContent()
}
},
{ immediate: true }
)
watch(
[visible, file, viewMode],
async ([open, currentFile, currentViewMode]) => {
if (!open || !currentFile || !hasSourcePreview.value || currentViewMode !== 'source') {
if (!open || !hasSourcePreview.value) {
resetSourcePreview()
}
return
}
await loadSourcePreview()
},
{ immediate: true }
)
// 统计信息
const mergeResult = computed(() => mergeChunks(contentState.value.lines || []))
const mappedChunks = computed(() => mergeResult.value.chunks)
const mergedContent = computed(() => contentState.value.content || mergeResult.value.content || '')
const charCount = computed(() => mergedContent.value.length)
const chunkCount = computed(
() => mappedChunks.value.length || contentState.value.lines?.length || 0
)
const viewInfoText = computed(() => {
if (viewMode.value === 'chunks') {
if (contentState.value.loading) return ''
return `${chunkCount.value} 个片段`
}
if (viewMode.value === 'source') {
if (sourcePreview.value.loading) return ''
if (sourceContentLength.value > 0) return `${formatTextLength(sourceContentLength.value)} 字符`
if (sourcePreview.value.url) return '源文件预览'
return ''
}
if (contentState.value.loading) return ''
return `${formatTextLength(charCount.value)} 字符`
})
// 格式化文本长度
function formatTextLength(length) {
if (!length && length !== 0) return '0 字符'
if (length < 1000) {
return `${length}`
} else {
return `${(length / 1000).toFixed(1)}k`
}
}
const afterOpenChange = (open) => {
if (!open) {
resetLocalState()
emit('closed')
}
}
const loadSourcePreview = async () => {
if (!file.value?.file_id || !props.kbId || !props.fileId || !hasSourcePreview.value) return
if (sourcePreview.value.url || sourcePreview.value.content || sourcePreview.value.message) return
const requestId = ++sourceRequestSeq
sourcePreview.value.loading = true
try {
const response = await getWorkspaceKnowledgeFileContent(props.kbId, props.fileId)
const preview = await normalizePreviewResponse(response)
if (requestId !== sourceRequestSeq) {
if (preview.previewUrl) {
window.URL.revokeObjectURL(preview.previewUrl)
}
return
}
revokeSourcePreviewUrl()
sourcePreview.value.type = preview.previewType || sourcePreviewCandidateType.value
sourcePreview.value.message = preview.message || ''
sourcePreview.value.supported = preview.supported !== false
sourcePreview.value.url = preview.previewUrl || ''
sourcePreview.value.content = preview.content || ''
} catch (error) {
if (requestId !== sourceRequestSeq) return
console.error('加载源文件预览失败:', error)
sourcePreview.value.message = error.message || '加载源文件预览失败'
sourcePreview.value.supported = false
message.error(sourcePreview.value.message)
} finally {
if (requestId === sourceRequestSeq) {
sourcePreview.value.loading = false
}
}
}
// 下载菜单点击处理
const handleDownloadMenuClick = ({ key }) => {
if (key === 'original') {
handleDownloadOriginal()
} else if (key === 'markdown') {
handleDownloadMarkdown()
}
}
// 下载原文
const handleDownloadOriginal = async () => {
if (!file.value || !props.kbId || !props.fileId) {
message.error('文件信息不完整')
return
}
downloadingOriginal.value = true
try {
const response = await documentApi.downloadDocument(props.kbId, props.fileId)
// 获取文件名
const contentDisposition = response.headers.get('content-disposition')
let filename = file.value.filename
if (contentDisposition) {
// 首先尝试匹配RFC 2231格式 filename*=UTF-8''...
const rfc2231Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/)
if (rfc2231Match) {
try {
filename = decodeURIComponent(rfc2231Match[1])
} catch (error) {
console.warn('Failed to decode RFC2231 filename:', rfc2231Match[1], error)
}
} else {
// 回退到标准格式 filename="..."
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1].replace(/['"]/g, '')
// 解码URL编码的文件名
try {
filename = decodeURIComponent(filename)
} catch (error) {
console.warn('Failed to decode filename:', filename, error)
}
}
}
}
// 创建blob并下载
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
} catch (error) {
console.error('下载文件时出错:', error)
message.error(error.message || '下载文件失败')
} finally {
downloadingOriginal.value = false
}
}
// 下载 Markdown
const handleDownloadMarkdown = () => {
const content = mergedContent.value
if (!content) {
message.error('没有可下载的 Markdown 内容')
return
}
downloadingMarkdown.value = true
try {
// 生成文件名(如果原文件没有 .md 扩展名,则添加)
let filename = file.value.filename || 'document.md'
if (!filename.toLowerCase().endsWith('.md')) {
// 移除原扩展名,添加 .md
const lastDotIndex = filename.lastIndexOf('.')
if (lastDotIndex > 0) {
filename = filename.substring(0, lastDotIndex) + '.md'
} else {
filename = filename + '.md'
}
}
// 创建 blob 并下载
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
} catch (error) {
console.error('下载 Markdown 时出错:', error)
message.error(error.message || '下载 Markdown 失败')
} finally {
downloadingMarkdown.value = false
}
}
onBeforeUnmount(resetLocalState)
</script>
<style scoped>
.file-detail-content {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.content-panel,
.chunks-panel {
flex: 1;
overflow-y: auto;
padding: 0;
min-height: 0;
}
.source-panel {
overflow: hidden;
}
:deep(.source-preview-container) {
height: 100%;
max-height: none;
}
:deep(.source-preview-content) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
}
:deep(.source-preview-content .html-preview),
:deep(.source-preview-content .pdf-preview) {
display: block;
height: 100%;
min-height: 100%;
}
.markdown-content {
min-height: 100%;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.empty-content {
text-align: center;
padding: 40px 0;
color: var(--gray-400);
width: 100%;
}
/* Chunks 面板样式 */
.chunk-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.chunk-card {
background: var(--gray-0);
border: 1px solid var(--gray-200);
border-radius: 8px;
padding: 12px;
transition: all 0.2s ease;
}
.chunk-card:hover {
border-color: var(--main-color);
box-shadow: 0 2px 8px rgba(1, 97, 121, 0.1);
}
.chunk-card-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.chunk-order {
font-weight: 600;
color: var(--main-color);
font-size: 12px;
}
.chunk-card-content {
font-size: 12px;
color: var(--gray-600);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
</style>
<style lang="less">
.file-detail {
.ant-modal {
top: 20px;
}
.ant-modal-header {
.ant-modal-title {
width: 100%;
}
}
}
.modal-title-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
}
/* 文件标题样式 */
.file-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
svg {
flex: 0 0 auto;
}
}
.file-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: 15px;
color: var(--gray-900);
}
.title-info {
font-size: 13px;
color: var(--gray-600);
font-weight: 500;
}
.header-controls {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
margin-left: auto;
min-width: 0;
}
/* 下载按钮样式 */
.download-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
min-width: 48px;
padding: 0 10px;
height: 28px;
line-height: 1;
border-radius: 6px;
gap: 4px;
svg {
flex: 0 0 auto;
vertical-align: middle;
}
}
/* 自定义关闭按钮 */
.custom-close-btn {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: var(--gray-500);
transition: all 0.2s;
&:hover {
background: var(--gray-100);
color: var(--gray-700);
}
}
/* 视图切换控件 */
.view-controls {
display: flex;
align-items: center;
flex: 0 0 auto;
.ant-segmented {
padding: 2px;
}
.ant-segmented-item {
min-width: 30px;
}
.ant-segmented-item-label {
display: flex;
align-items: center;
justify-content: center;
height: 24px;
min-height: 24px;
padding: 0 7px;
line-height: 24px;
}
}
.view-option-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.view-info {
flex: 0 0 auto;
font-size: 12px;
color: var(--gray-500);
white-space: nowrap;
}
/* 下拉菜单样式 */
.ant-dropdown-menu {
border-radius: 8px;
padding: 4px;
}
.ant-dropdown-menu-item {
border-radius: 6px;
display: flex;
align-items: center;
padding: 8px 12px;
svg {
margin-right: 8px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
<template>
<div class="file-tree-wrapper" :class="treeClass">
<a-tree
:selected-keys="selectedKeys"
:expanded-keys="expandedKeys"
:tree-data="treeData"
:load-data="loadData ? internalLoadData : undefined"
:show-icon="showIcon"
:block-node="blockNode"
:show-line="showLine"
class="custom-file-tree"
@update:selected-keys="handleSelectedUpdate"
@update:expanded-keys="handleExpandedUpdate"
@select="handleSelect"
>
<!-- Custom Icon Slot -->
<template #icon="{ data, expanded }">
<slot name="icon" :node="data" :expanded="expanded">
<template v-if="data.isLeaf">
<FileTypeIcon v-if="useFileIcons" :name="data.key" :size="16" />
<FileText v-else :size="16" class="file-icon" />
</template>
<template v-else>
<span
v-if="isNodeLoading(data.key)"
class="folder-loading-icon"
aria-label="正在加载"
></span>
<FileTypeIcon v-else is-dir :size="18" />
</template>
</slot>
</template>
<!-- Custom Title Slot -->
<template #title="{ data }">
<div class="tree-node-wrapper" @click="handleNodeClick(data)">
<div class="tree-node-content">
<slot name="title" :node="data">
<span class="node-title-text" :title="data.title">{{ data.title }}</span>
</slot>
</div>
<div class="node-actions" @click.stop v-if="$slots.actions">
<slot name="actions" :node="data"></slot>
</div>
</div>
</template>
</a-tree>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { FileText } from 'lucide-vue-next'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
const props = defineProps({
treeData: {
type: Array,
required: true,
default: () => []
},
loadData: {
type: Function,
default: undefined
},
selectedKeys: {
type: Array,
default: () => []
},
expandedKeys: {
type: Array,
default: () => []
},
showIcon: {
type: Boolean,
default: true
},
blockNode: {
type: Boolean,
default: true
},
showLine: {
type: Boolean,
default: false
},
treeClass: {
type: String,
default: ''
},
useFileIcons: {
type: Boolean,
default: true
}
})
const emit = defineEmits([
'update:selectedKeys',
'update:expandedKeys',
'select',
'nodeClick',
'toggleFolder'
])
const loadingKeys = ref(new Set())
const setNodeLoading = (key, isLoading) => {
const nextLoadingKeys = new Set(loadingKeys.value)
if (isLoading) {
nextLoadingKeys.add(key)
} else {
nextLoadingKeys.delete(key)
}
loadingKeys.value = nextLoadingKeys
}
const isNodeLoading = (key) => loadingKeys.value.has(key)
const internalLoadData = async (treeNode) => {
if (!props.loadData) return
const key = treeNode?.key
if (key) setNodeLoading(key, true)
try {
await props.loadData(treeNode)
} finally {
if (key) setNodeLoading(key, false)
}
}
const handleSelectedUpdate = (keys) => {
emit('update:selectedKeys', keys)
}
const handleExpandedUpdate = (keys) => {
emit('update:expandedKeys', keys)
}
const handleSelect = (selectedKeys, info) => {
emit('select', selectedKeys, info)
}
const handleNodeClick = (data) => {
emit('nodeClick', data)
const isFolder = data.isLeaf === false || (data.children && Array.isArray(data.children))
if (isFolder) {
const key = data.key
const newExpandedKeys = [...props.expandedKeys]
const index = newExpandedKeys.indexOf(key)
if (index > -1) {
newExpandedKeys.splice(index, 1)
} else {
newExpandedKeys.push(key)
}
emit('update:expandedKeys', newExpandedKeys)
emit('toggleFolder', data, newExpandedKeys.indexOf(key) > -1)
}
}
</script>
<style scoped lang="less">
.file-tree-wrapper {
width: 100%;
/* 统一节点容器 */
:deep(.ant-tree-treenode) {
display: flex;
align-items: center;
width: 100%;
padding: 0;
height: 32px;
/* 隐藏切换器 */
.ant-tree-switcher {
display: none;
}
/* 缩进单元 */
.ant-tree-indent {
display: flex;
align-items: center;
align-self: stretch;
&-unit {
width: 14px;
}
}
/* 内容区域容器 */
.ant-tree-node-content-wrapper {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 32px;
line-height: 32px;
padding: 0 4px;
border-radius: 4px;
transition: background-color 0.2s;
/* 图标容器 */
.ant-tree-iconEle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 100%;
margin-right: 4px;
flex-shrink: 0;
.anticon {
display: flex;
align-items: center;
}
}
/* 标题文字容器 */
.ant-tree-title {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 100%;
}
&:hover {
background-color: var(--gray-50);
}
&.ant-tree-node-selected {
background-color: var(--gray-100);
}
}
}
}
.tree-node-wrapper {
display: flex;
align-items: center;
width: 100%;
height: 100%;
min-width: 0;
}
.tree-node-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.node-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--gray-800);
}
.folder-icon {
color: var(--main-500);
fill: var(--main-500);
fill-opacity: 0.2;
}
.folder-loading-icon {
width: 15px;
height: 15px;
border: 2px solid var(--gray-200);
border-top-color: var(--main-500);
border-radius: 50%;
animation: file-tree-folder-loading 0.8s linear infinite;
}
@keyframes file-tree-folder-loading {
to {
transform: rotate(360deg);
}
}
.node-actions {
display: none;
align-items: center;
padding-left: 8px;
flex-shrink: 0;
}
.ant-tree-node-content-wrapper:hover .node-actions {
display: flex;
}
</style>
File diff suppressed because it is too large Load Diff
+833
View File
@@ -0,0 +1,833 @@
<template>
<div class="graph-canvas-container" ref="rootEl">
<div v-show="graphData.nodes.length > 0" class="graph-canvas" ref="container"></div>
<div class="slots">
<div v-if="$slots.top" class="overlay top">
<slot name="top" />
</div>
<div class="canvas-content">
<slot name="content" />
</div>
<div class="graph-stats-wrapper" v-if="graphData.nodes.length > 0">
<div v-if="activeStatsPanel" class="floating-panel type-stats-card">
<div class="panel-header">
<span class="panel-title">
{{ activeStatsPanel === 'node' ? '实体类型' : '关系类型' }}
</span>
</div>
<div class="panel-body">
<div class="type-stats-list">
<div
v-for="item in activeTypeStats"
:key="item.name"
class="type-stats-row"
:title="`${item.name}: ${item.count}`"
>
<span class="type-color" :style="{ backgroundColor: item.color }"></span>
<span class="type-name">{{ item.name }}</span>
<span class="type-count">{{ item.count }}</span>
</div>
</div>
</div>
</div>
<div class="floating-panel graph-stats-panel">
<button
class="stat-item"
:class="{ active: activeStatsPanel === 'node' }"
type="button"
@click="toggleStatsPanel('node')"
>
<span class="stat-label">实体</span>
<span class="stat-value">{{ visibleEntityCount }}</span>
</button>
<button
class="stat-item"
:class="{ active: activeStatsPanel === 'edge' }"
type="button"
@click="toggleStatsPanel('edge')"
>
<span class="stat-label">关系</span>
<span class="stat-value">{{ visibleRelationshipCount }}</span>
</button>
</div>
</div>
<div v-if="$slots.bottom" class="overlay bottom">
<slot name="bottom" />
</div>
</div>
</div>
</template>
<script setup>
import { Graph } from '@antv/g6'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useThemeStore } from '@/stores/theme'
const props = defineProps({
graphData: {
type: Object,
required: true,
default: () => ({ nodes: [], edges: [] })
},
graphInfo: {
type: Object,
default: () => ({})
},
labelField: { type: String, default: 'name' },
autoFit: { type: Boolean, default: true },
autoResize: { type: Boolean, default: true },
layoutOptions: { type: Object, default: () => ({}) },
nodeStyleOptions: { type: Object, default: () => ({}) },
edgeStyleOptions: { type: Object, default: () => ({}) },
enableFocusNeighbor: { type: Boolean, default: true },
sizeByDegree: { type: Boolean, default: true },
highlightKeywords: { type: Array, default: () => [] }
})
const emit = defineEmits(['ready', 'data-rendered', 'node-click', 'edge-click', 'canvas-click'])
const container = ref(null)
const rootEl = ref(null)
const themeStore = useThemeStore()
const activeStatsPanel = ref('')
let graphInstance = null
let resizeObserver = null
let renderTimeout = null
let retryCount = 0
let resizeTimer = null
let layoutTimeout = null
let highlightTimeout = null
let isMounted = false
const MAX_RETRIES = 5
const defaultLayout = {
type: 'd3-force',
preventOverlap: true,
alphaDecay: 0.1,
alphaMin: 0.01,
velocityDecay: 0.6,
iterations: 150,
force: {
center: { x: 0.5, y: 0.5, strength: 0.1 },
charge: { strength: -400, distanceMax: 600 },
link: { distance: 100, strength: 0.8 }
},
collide: { radius: 40, strength: 0.8, iterations: 3 }
}
const CHUNK_NODE_LABEL = 'Chunk'
const CHUNK_NODE_COLOR = '#8c8c8c'
const CHUNK_MENTION_EDGE_LABEL = 'MENTIONS'
const NODE_LABEL_COLORS = [
'#3996ae',
'#5ad8a6',
'#f6bd16',
'#f27c7c',
'#9581cc',
'#6dc8ec',
'#ff9d4d',
'#92d050',
'#e885ba'
]
const EDGE_LABEL_COLORS = [
'#99add1',
'#3996ae',
'#13c2c2',
'#faad14',
'#f27c7c',
'#9581cc',
'#52c41a',
'#ff9d4d'
]
// CSS 变量解析工具函数
function getCSSVariable(variableName, element = document.documentElement) {
return getComputedStyle(element).getPropertyValue(variableName).trim()
}
function hashString(value) {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
function getPaletteColor(label, colors) {
return colors[hashString(label) % colors.length]
}
function getNodeVisualLabel(node) {
return node?.type || node?.normalized?.type || node?.properties?.label || 'Entity'
}
function getNodeColor(node) {
const label = getNodeVisualLabel(node)
if (label === CHUNK_NODE_LABEL) return CHUNK_NODE_COLOR
return getPaletteColor(label, NODE_LABEL_COLORS)
}
function getEdgeVisualLabel(edge) {
return edge?.type || edge?.normalized?.type || 'RELATED_TO'
}
function getEdgeColor(edge) {
return getPaletteColor(getEdgeVisualLabel(edge), EDGE_LABEL_COLORS)
}
function isEntityNode(node) {
return getNodeVisualLabel(node) !== CHUNK_NODE_LABEL
}
function isEntityRelationEdge(edge) {
return getEdgeVisualLabel(edge) !== CHUNK_MENTION_EDGE_LABEL
}
function buildTypeStats(items, getName, getColor) {
const counts = new Map()
for (const item of items || []) {
const name = getName(item)
counts.set(name, (counts.get(name) || 0) + 1)
}
return Array.from(counts, ([name, count]) => ({
name,
count,
color: getColor({ type: name, normalized: { type: name } })
})).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
}
const visibleEntityNodes = computed(() => (props.graphData?.nodes || []).filter(isEntityNode))
const visibleRelationshipEdges = computed(() =>
(props.graphData?.edges || []).filter(isEntityRelationEdge)
)
const visibleEntityCount = computed(() => visibleEntityNodes.value.length)
const visibleRelationshipCount = computed(() => visibleRelationshipEdges.value.length)
const nodeTypeStats = computed(() =>
buildTypeStats(visibleEntityNodes.value, getNodeVisualLabel, getNodeColor)
)
const edgeTypeStats = computed(() =>
buildTypeStats(visibleRelationshipEdges.value, getEdgeVisualLabel, getEdgeColor)
)
const activeTypeStats = computed(() =>
activeStatsPanel.value === 'node' ? nodeTypeStats.value : edgeTypeStats.value
)
function toggleStatsPanel(type) {
activeStatsPanel.value = activeStatsPanel.value === type ? '' : type
}
function formatData() {
const data = props.graphData || { nodes: [], edges: [] }
const degrees = new Map()
for (const n of data.nodes) {
degrees.set(String(n.id), 0)
}
for (const e of data.edges) {
const s = String(e.source_id)
const t = String(e.target_id)
degrees.set(s, (degrees.get(s) || 0) + 1)
degrees.set(t, (degrees.get(t) || 0) + 1)
}
const nodes = (data.nodes || []).map((n) => ({
id: String(n.id),
data: {
label: n[props.labelField] ?? n.name ?? String(n.id),
visualLabel: getNodeVisualLabel(n),
color: getNodeColor(n),
degree: degrees.get(String(n.id)) || 0,
original: n // 保存原始数据
}
}))
const edges = (data.edges || []).map((e, idx) => ({
id: e.id ? String(e.id) : `edge-${idx}`,
source: String(e.source_id),
target: String(e.target_id),
data: {
label: e.type ?? '',
visualLabel: getEdgeVisualLabel(e),
color: getEdgeColor(e),
original: e // 保存原始数据
}
}))
return { nodes, edges }
}
function initGraph() {
if (!container.value) return
const width = container.value.offsetWidth
const height = container.value.offsetHeight
if (width === 0 && height === 0) {
if (retryCount < MAX_RETRIES) {
retryCount++
clearTimeout(renderTimeout)
renderTimeout = setTimeout(initGraph, 200)
}
return
}
retryCount = 0
container.value.innerHTML = ''
if (graphInstance) {
try {
graphInstance.destroy()
} catch {
// ignore cleanup error
}
graphInstance = null
}
graphInstance = new Graph({
container: container.value,
width,
height,
autoFit: props.autoFit,
autoResize: props.autoResize,
layout: { ...defaultLayout, ...props.layoutOptions },
node: {
type: 'circle',
style: {
labelText: (d) => d.data.label,
labelFill: getCSSVariable('--gray-700'),
labelWordWrap: true, // enable label ellipsis
labelMaxWidth: '300%',
size: (d) => {
if (!props.sizeByDegree) return 24
const deg = d.data.degree || 0
return Math.min(15 + deg * 5, 50)
},
fill: (d) => d.data.color,
opacity: 0.9,
stroke: getCSSVariable('--color-bg-container'),
lineWidth: 1.5,
shadowColor: getCSSVariable('--gray-400'),
shadowBlur: 4,
...(props.nodeStyleOptions.style || {})
},
palette: props.nodeStyleOptions.palette
},
edge: {
type: 'quadratic',
style: {
labelText: (d) => d.data.label,
labelFill: getCSSVariable('--gray-800'),
labelBackground: true,
labelBackgroundFill: getCSSVariable('--gray-100'),
stroke: (d) => d.data.color,
opacity: 0.8,
lineWidth: 1.2,
endArrow: true,
...(props.edgeStyleOptions.style || {})
},
palette: props.edgeStyleOptions.palette
},
behaviors: [
'drag-element',
'zoom-canvas',
'drag-canvas',
'hover-activate',
{
type: 'click-select',
degree: 1,
state: 'selected', // 选中的状态
neighborState: 'active', // 相邻节点附着状态
unselectedState: 'inactive', // 未选中节点状态
multiple: true,
trigger: ['shift'],
// 禁用默认的选中效果,避免与自定义事件冲突
disableDefault: false
}
]
})
// 绑定事件
graphInstance.on('node:click', (evt) => {
const { target } = evt
// 获取节点ID
const nodeId = target.id
const nodeData = graphInstance.getNodeData(nodeId)
emit('node-click', nodeData)
})
graphInstance.on('edge:click', (evt) => {
const { target } = evt
const edgeId = target.id
const edgeData = graphInstance.getEdgeData(edgeId)
emit('edge-click', edgeData)
})
graphInstance.on('canvas:click', (evt) => {
// 只有点击画布空白处才触发
if (!evt.target) {
emit('canvas-click')
}
})
emit('ready', graphInstance)
}
function setGraphData() {
if (!graphInstance || !isMounted) initGraph()
if (!graphInstance || !isMounted) return
const data = formatData()
console.log('开始设置图谱数据:', {
nodes: data.nodes.length,
edges: data.edges.length
})
graphInstance.setData(data)
graphInstance.render()
// 手动触发布局重新计算,确保节点分布
clearTimeout(layoutTimeout)
layoutTimeout = setTimeout(() => {
if (!isMounted || !graphInstance) return
try {
if (graphInstance && graphInstance.layout) {
graphInstance.layout()
console.log('触发布局重新计算')
}
} catch (error) {
console.warn('布局重新计算失败:', error)
}
// 等待力导向布局稳定后再应用高亮
clearTimeout(highlightTimeout)
highlightTimeout = setTimeout(() => {
if (!isMounted || !graphInstance) return
applyHighlightKeywords()
emit('data-rendered')
console.log('图谱渲染完成,布局已稳定')
}, 1500)
}, 10) // 等待 10ms 确保布局完成
}
// 关键词高亮功能
function applyHighlightKeywords() {
if (!graphInstance || !props.highlightKeywords || props.highlightKeywords.length === 0) return
const { nodes } = graphInstance.getData()
const updates = {}
nodes.forEach((node) => {
const nodeLabel = node.data.label || node.data[props.labelField] || String(node.id)
const shouldHighlight = props.highlightKeywords.some(
(keyword) => keyword.trim() !== '' && nodeLabel.toLowerCase().includes(keyword.toLowerCase())
)
if (shouldHighlight) {
updates[node.id] = ['highlighted']
}
})
if (Object.keys(updates).length > 0) {
graphInstance.setElementState(updates)
graphInstance.draw()
}
}
// 清除高亮
function clearHighlights() {
if (!graphInstance) return
const { nodes } = graphInstance.getData()
const updates = {}
nodes.forEach((node) => {
updates[node.id] = []
})
if (Object.keys(updates).length > 0) {
graphInstance.setElementState(updates)
graphInstance.draw()
}
}
function renderGraph() {
if (!graphInstance) initGraph()
setGraphData()
}
function refreshGraph() {
if (!isMounted) return
if (graphInstance) {
try {
graphInstance.destroy()
} catch {
// ignore cleanup error
}
graphInstance = null
}
if (container.value) container.value.innerHTML = ''
retryCount = 0
clearTimeout(renderTimeout)
renderTimeout = setTimeout(() => {
if (isMounted) renderGraph()
}, 300)
}
function fitView() {
if (graphInstance)
try {
graphInstance.fitView()
} catch {
// ignore
}
}
function fitCenter() {
if (graphInstance)
try {
graphInstance.fitCenter()
} catch {
// ignore
}
}
function getInstance() {
return graphInstance
}
async function focusNode(id) {
if (!graphInstance || !props.enableFocusNeighbor) return
const { nodes, edges } = graphInstance.getData()
const nodeIds = nodes.map((n) => n.id)
const edgeIds = edges.map((e) => e.id)
const updates = {}
nodeIds.forEach((nid) => (updates[nid] = ['hidden']))
edgeIds.forEach((eid) => (updates[eid] = ['hidden']))
const neighborSet = new Set()
const related = []
edges.forEach((e) => {
if (e.source === id) {
neighborSet.add(e.target)
related.push(e.id)
} else if (e.target === id) {
neighborSet.add(e.source)
related.push(e.id)
}
})
updates[id] = ['focus']
Array.from(neighborSet).forEach((nid) => (updates[nid] = ['focus']))
related.forEach((eid) => (updates[eid] = ['focus']))
await graphInstance.setElementState(updates)
await graphInstance.draw()
}
async function clearFocus() {
if (!graphInstance) return
const { nodes, edges } = graphInstance.getData()
const nodeIds = nodes.map((n) => n.id)
const edgeIds = edges.map((e) => e.id)
const updates = {}
nodeIds.forEach((nid) => (updates[nid] = []))
edgeIds.forEach((eid) => (updates[eid] = []))
await graphInstance.setElementState(updates)
await graphInstance.draw()
}
watch(
() => props.graphData,
() => {
if (!isMounted) return
clearTimeout(renderTimeout)
renderTimeout = setTimeout(() => setGraphData(), 50)
},
{ deep: true }
)
// 监听关键词变化
watch(
() => props.highlightKeywords,
() => {
if (graphInstance && isMounted) {
clearHighlights()
setTimeout(() => {
if (isMounted) applyHighlightKeywords()
}, 50)
}
},
{ deep: true }
)
// 监听主题切换,重新加载图形
watch(
() => themeStore.isDark,
() => {
if (graphInstance && isMounted) {
refreshGraph()
}
}
)
onMounted(() => {
isMounted = true
// ResizeObserver 监听容器尺寸,自动重渲染
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => {
if (!container.value || !graphInstance || !isMounted) return
const width = container.value.offsetWidth
const height = container.value.offsetHeight
if (width === 0 && height === 0) return
graphInstance.setSize(width, height)
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
if (graphInstance && isMounted) {
graphInstance.fitView()
}
}, 150)
})
if (container.value) resizeObserver.observe(container.value)
}
clearTimeout(renderTimeout)
renderTimeout = setTimeout(() => {
if (isMounted) renderGraph()
}, 300)
window.addEventListener('resize', refreshGraph)
})
onUnmounted(() => {
isMounted = false
window.removeEventListener('resize', refreshGraph)
if (resizeObserver && container.value) resizeObserver.unobserve(container.value)
clearTimeout(renderTimeout)
clearTimeout(resizeTimer)
clearTimeout(layoutTimeout)
clearTimeout(highlightTimeout)
try {
graphInstance?.destroy()
} catch {
// ignore cleanup error
}
graphInstance = null
})
// 暴露方法
defineExpose({
refreshGraph,
fitView,
fitCenter,
getInstance,
focusNode,
clearFocus,
setData: setGraphData,
applyHighlightKeywords,
clearHighlights
})
</script>
<style lang="less">
.graph-canvas-container {
position: relative;
width: 100%;
height: 100%;
// background-color: var(--gray-0);
.graph-canvas {
width: 100%;
height: 100%;
}
.graph-stats-wrapper {
position: absolute;
bottom: 10px;
left: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
pointer-events: auto;
z-index: 10;
}
.floating-panel {
background: var(--color-trans-light);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--gray-100);
border-radius: 8px;
box-shadow: 0 0 4px 0px var(--shadow-2);
font-size: 13px;
user-select: auto;
.panel-header {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--gray-200);
}
.panel-title {
color: var(--gray-1000);
font-size: 13px;
font-weight: 600;
line-height: 1.2;
}
.panel-body {
padding: 10px 14px;
}
}
.graph-stats-panel {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 12px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
line-height: 1.4;
&:hover,
&.active {
.stat-label,
.stat-value {
color: var(--main-color);
}
}
.stat-label {
color: var(--color-text-secondary);
font-weight: 500;
}
.stat-value {
color: var(--color-text);
font-weight: 600;
}
.stat-total {
color: var(--color-text-quaternary);
font-size: 11px;
}
}
}
.type-stats-card {
width: 220px;
max-width: calc(100vw - 40px);
overflow: hidden;
}
.type-stats-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 220px;
overflow-y: auto;
padding-right: 2px;
}
.type-stats-row {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 6px;
border-radius: 6px;
&:hover {
background: var(--gray-50);
}
}
.type-color {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow:
0 0 0 1px var(--color-bg-container),
0 0 0 2px var(--gray-200);
}
.type-name {
min-width: 0;
overflow: hidden;
color: var(--gray-800);
text-overflow: ellipsis;
white-space: nowrap;
}
.type-count {
color: var(--gray-1000);
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.slots {
// 让整层覆盖容器默认不接收指针事件(便于穿透到底下画布)
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
z-index: 999;
.overlay {
width: 100%;
flex-shrink: 0;
flex-grow: 0;
pointer-events: auto;
&.top {
top: 0;
}
&.bottom {
bottom: 0;
}
}
.canvas-content {
// 中间内容层及其子元素全部穿透
pointer-events: none;
flex: 1;
background: transparent !important;
}
.canvas-content * {
pointer-events: none;
}
}
}
/* 高亮节点的脉冲动画效果 */
@keyframes highlightPulse {
0% {
filter: brightness(1);
}
50% {
filter: brightness(1.3) drop-shadow(0 0 8px rgba(255, 0, 0, 0.8));
}
100% {
filter: brightness(1);
}
}
.highlight-animation {
animation: highlightPulse 2s infinite ease-in-out;
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<template>
<transition name="slide-fade-left">
<div class="detail-panel" v-if="visible" @click="handleLinkClick">
<div class="panel-header">
<span class="panel-title">{{ title }}</span>
<X :size="14" class="close-icon" @click="$emit('close')" />
</div>
<div class="panel-body">
<template v-if="item">
<template v-if="type === 'node'">
<div :class="rowClass(item.data?.label)">
<span class="detail-label">名称</span>
<span class="detail-value">
<DetailValue
:value="item.data?.label"
field-key="__label__"
:expanded-keys="expandedKeys"
/>
</span>
</div>
<div class="detail-row">
<span class="detail-label">ID</span>
<span class="detail-value detail-id">{{ item.id }}</span>
</div>
<template v-if="item.data?.original?.labels">
<div class="detail-row">
<span class="detail-label">标签</span>
<span class="detail-value">
<a-tag v-for="tag in item.data.original.labels" :key="tag" size="small">{{
tag
}}</a-tag>
</span>
</div>
</template>
<template v-if="item.data?.original?.properties">
<div
v-for="(value, key) in item.data.original.properties"
:key="key"
:class="rowClass(value)"
>
<span class="detail-label">{{ key }}</span>
<span class="detail-value">
<DetailValue :value="value" :field-key="key" :expanded-keys="expandedKeys" />
</span>
</div>
</template>
</template>
<template v-else-if="type === 'edge'">
<div :class="rowClass(item.data?.label)">
<span class="detail-label">类型</span>
<span class="detail-value">
<DetailValue
:value="item.data?.label"
field-key="__type__"
:expanded-keys="expandedKeys"
/>
</span>
</div>
<template v-if="item.data?.original?.properties">
<div
v-for="(value, key) in filteredEdgeProperties"
:key="key"
:class="rowClass(value)"
>
<span class="detail-label">{{ key }}</span>
<span class="detail-value">
<DetailValue :value="value" :field-key="key" :expanded-keys="expandedKeys" />
</span>
</div>
</template>
</template>
</template>
</div>
</div>
</transition>
</template>
<script setup>
import { computed, reactive, watch, defineComponent, h } from 'vue'
import { X } from 'lucide-vue-next'
const STACK_THRESHOLD = 50
const TRUNCATE_LIMIT = 100
const DetailValue = defineComponent({
props: {
value: [String, Number, Boolean, Object, Array],
fieldKey: { type: String, required: true },
expandedKeys: { type: Set, required: true }
},
setup(props) {
return () => {
const v = props.value
if (typeof v !== 'string') return String(v ?? '')
if (v.length <= TRUNCATE_LIMIT) return v
if (props.expandedKeys.has(props.fieldKey)) {
return [
v,
h(
'a',
{
class: 'expand-link',
onClick: (e) => {
e.preventDefault()
props.expandedKeys.delete(props.fieldKey)
}
},
' 收起'
)
]
}
return [
v.slice(0, TRUNCATE_LIMIT) + '...',
h(
'a',
{
class: 'expand-link',
onClick: (e) => {
e.preventDefault()
props.expandedKeys.add(props.fieldKey)
}
},
'展开'
)
]
}
}
})
const props = defineProps({
visible: Boolean,
item: Object,
type: String
})
defineEmits(['close'])
const expandedKeys = reactive(new Set())
watch(
() => props.item,
() => {
expandedKeys.clear()
}
)
const isOverThreshold = (value) => typeof value === 'string' && value.length > STACK_THRESHOLD
const rowClass = (value) => {
return isOverThreshold(value) ? 'detail-row detail-row--stack' : 'detail-row'
}
const title = computed(() => {
return props.type === 'node' ? '节点详情' : '关系详情'
})
const filteredEdgeProperties = computed(() => {
if (!props.item?.data?.original?.properties) return {}
const properties = props.item.data.original.properties
const filtered = {}
const hiddenFields = ['source_id', 'target_id', '_id', 'truncate']
Object.keys(properties).forEach((key) => {
if (!hiddenFields.includes(key)) filtered[key] = properties[key]
})
return filtered
})
</script>
<style scoped lang="less">
.detail-panel {
position: absolute;
top: 60px;
left: 10px;
width: 280px;
max-height: calc(100% - 60px);
overflow-y: auto;
z-index: 100;
background: var(--color-trans-light);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 8px;
border: 1px solid var(--gray-100);
box-shadow: 0 0 4px 0px var(--shadow-2);
font-size: 13px;
user-select: auto;
.panel-header {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--gray-200);
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--gray-1000);
}
.close-icon {
margin-left: auto;
cursor: pointer;
color: var(--gray-500);
transition: color 0.2s;
&:hover {
color: var(--gray-800);
}
}
}
.panel-body {
padding: 10px 14px;
}
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 6px 0;
border-bottom: 1px solid var(--gray-50);
&:last-child {
border-bottom: none;
}
&--stack {
flex-direction: column;
}
.detail-label {
flex-shrink: 0;
color: var(--gray-500);
font-size: 12px;
margin-right: 8px;
margin-bottom: 2px;
}
.detail-value {
text-align: right;
color: var(--gray-800);
font-size: 13px;
word-break: break-all;
&.detail-id {
font-size: 11px;
color: var(--gray-500);
}
:deep(.expand-link) {
cursor: pointer;
color: var(--main-700);
font-size: 12px;
white-space: nowrap;
margin-left: 2px;
&:hover {
color: var(--main-500);
}
}
}
}
.detail-row--stack {
.detail-value {
text-align: left;
}
}
.slide-fade-left-enter-active {
transition: all 0.25s ease-out;
}
.slide-fade-left-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-left-enter-from,
.slide-fade-left-leave-to {
transform: translateX(-20px);
opacity: 0;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="header-container">
<div class="header-content">
<div class="header-actions" v-if="$slots.left">
<slot name="left"></slot>
</div>
<div class="header-title">
<div class="header-title-block">
<h1>{{ title }}</h1>
<slot name="behind-title"></slot>
</div>
<slot name="description">
<p v-if="description">{{ description }}</p>
</slot>
</div>
<div class="header-actions" v-if="$slots.actions">
<loading-outlined v-if="loading" />
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
<script setup>
import { LoadingOutlined } from '@ant-design/icons-vue'
defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
default: ''
},
loading: {
type: Boolean,
default: false
}
})
</script>
<style scoped lang="less">
.header-container {
background-color: var(--bg-sider);
backdrop-filter: blur(10px);
padding: 8px var(--page-padding);
height: 50px;
position: sticky;
top: 0;
z-index: 1000;
}
.header-content {
display: flex;
width: 100%;
height: 100%;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.header-title {
flex: 1;
width: 100%;
font-size: 14px;
color: var(--gray-900);
.header-title-block {
display: flex;
align-items: baseline;
gap: 10px;
}
h1 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--gray-2000);
}
p {
margin: 4px 0 0;
}
}
.header-actions {
display: flex;
gap: 8px;
}
</style>
+650
View File
@@ -0,0 +1,650 @@
<template>
<transition name="slide-up">
<div v-if="visible" class="approval-modal">
<div class="approval-content">
<div v-if="normalizedQuestions.length > 1" class="question-tabs">
<button
v-for="(questionItem, questionIndex) in normalizedQuestions"
:key="questionItem.questionId"
class="tab-item"
:class="{
active: questionIndex === activeQuestionIndex,
completed: isQuestionAnswered(questionItem)
}"
:disabled="isProcessing"
@click="setActiveQuestion(questionIndex)"
>
<span class="tab-index">{{ questionIndex + 1 }}</span>
</button>
</div>
<div v-if="activeQuestion" class="question-block">
<div class="approval-header">
<h4>{{ activeQuestionIndex + 1 }}. {{ activeQuestion.question }}</h4>
</div>
<div v-if="activeQuestion.operation" class="approval-operation">
<span class="label">操作</span>
<span class="operation-text">{{ activeQuestion.operation }}</span>
</div>
<div class="question-options">
<label
v-for="(optionItem, optionIndex) in activeQuestion.options"
:key="`${activeQuestion.questionId}-${optionItem.value}-${optionIndex}`"
class="option-item"
>
<input
v-if="activeQuestion.multiSelect"
type="checkbox"
:value="optionItem.value"
:checked="getSelected(activeQuestion.questionId).includes(optionItem.value)"
:disabled="isProcessing"
@change="toggleSelect(activeQuestion.questionId, optionItem.value)"
/>
<input
v-else
type="radio"
:name="`approval-option-${activeQuestion.questionId}`"
:value="optionItem.value"
:checked="getSelected(activeQuestion.questionId)[0] === optionItem.value"
:disabled="isProcessing"
@change="setSingle(activeQuestion.questionId, optionItem.value)"
/>
<span
:class="{
recommended:
optionIndex === 0 && String(optionItem.label).includes('(Recommended)')
}"
>
{{ optionItem.label }}
</span>
</label>
<div v-if="shouldShowOtherInput(activeQuestion)" class="other-input">
<textarea
ref="otherTextareaRef"
:value="otherTexts[activeQuestion.questionId] || ''"
:disabled="isProcessing"
rows="1"
placeholder="其他:请输入自定义内容"
@input="handleOtherTextInput(activeQuestion.questionId, $event)"
></textarea>
</div>
</div>
</div>
</div>
<div class="approval-actions">
<button class="btn btn-reject" @click="handleCancel" :disabled="isProcessing">取消</button>
<button
class="btn btn-approve"
@click="handlePrimaryAction"
:disabled="isPrimaryButtonDisabled"
>
{{ primaryButtonText }}
</button>
</div>
<div v-if="isProcessing" class="approval-processing">
<span class="processing-spinner"></span>
处理中...
</div>
</div>
</transition>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import {
isOtherOption,
normalizeQuestions,
DEFAULT_OTHER_OPTION_VALUE
} from '@/utils/questionUtils'
const props = defineProps({
visible: { type: Boolean, default: false },
questions: { type: Array, default: () => [] }
})
const emit = defineEmits(['submit', 'cancel'])
const isProcessing = ref(false)
const activeQuestionIndex = ref(0)
const selectedValues = ref({})
const otherTexts = ref({})
const otherTextareaRef = ref(null)
const OTHER_TEXTAREA_MAX_ROWS = 4
const normalizedQuestions = computed(() => {
const questions = normalizeQuestions(props.questions)
// 添加 otherOptionValue 字段
return questions.map((q) => {
const otherOption = q.options.find((opt) => isOtherOption(opt))
return {
...q,
otherOptionValue: otherOption?.value || DEFAULT_OTHER_OPTION_VALUE
}
})
})
const activeQuestion = computed(() => {
if (normalizedQuestions.value.length === 0) return null
const index = Math.min(activeQuestionIndex.value, normalizedQuestions.value.length - 1)
return normalizedQuestions.value[index]
})
const resetForm = () => {
isProcessing.value = false
activeQuestionIndex.value = 0
selectedValues.value = {}
otherTexts.value = {}
}
const adjustOtherTextareaHeight = () => {
const textarea = otherTextareaRef.value
if (!textarea) return
const style = window.getComputedStyle(textarea)
const lineHeight = Number.parseFloat(style.lineHeight) || 20
const paddingY =
(Number.parseFloat(style.paddingTop) || 0) + (Number.parseFloat(style.paddingBottom) || 0)
const borderY =
(Number.parseFloat(style.borderTopWidth) || 0) +
(Number.parseFloat(style.borderBottomWidth) || 0)
const maxHeight = lineHeight * OTHER_TEXTAREA_MAX_ROWS + paddingY + borderY
textarea.style.height = 'auto'
textarea.style.maxHeight = `${maxHeight}px`
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
}
const handleOtherTextInput = (questionId, event) => {
otherTexts.value[questionId] = event.target.value
adjustOtherTextareaHeight()
}
const setActiveQuestion = (index) => {
if (isProcessing.value) return
if (index < 0 || index >= normalizedQuestions.value.length) return
activeQuestionIndex.value = index
nextTick(() => {
adjustOtherTextareaHeight()
})
}
const syncAnswersWithQuestions = () => {
const nextSelectedValues = {}
const nextOtherTexts = {}
normalizedQuestions.value.forEach((questionItem) => {
const questionId = questionItem.questionId
const previousSelected = Array.isArray(selectedValues.value[questionId])
? selectedValues.value[questionId]
: []
const validSelected = previousSelected.filter((value) =>
questionItem.options.some((option) => option?.value === value)
)
if (questionItem.multiSelect) {
nextSelectedValues[questionId] = validSelected
} else {
const current = validSelected[0]
if (current) {
nextSelectedValues[questionId] = [current]
} else if (questionItem.options.length > 0) {
nextSelectedValues[questionId] = [questionItem.options[0].value]
} else {
nextSelectedValues[questionId] = []
}
}
const text = String(otherTexts.value[questionId] || '').trim()
if (text) {
nextOtherTexts[questionId] = text
}
})
selectedValues.value = nextSelectedValues
otherTexts.value = nextOtherTexts
}
const getSelected = (questionId) => {
const selected = selectedValues.value[questionId]
return Array.isArray(selected) ? selected : []
}
const isQuestionOtherSelected = (questionItem) => {
const selected = getSelected(questionItem.questionId)
return selected.includes(questionItem.otherOptionValue)
}
const shouldShowOtherInput = (questionItem) => {
if (!questionItem || !questionItem.allowOther) return false
return isQuestionOtherSelected(questionItem)
}
watch(
() => props.visible,
(newVal) => {
if (newVal) {
activeQuestionIndex.value = 0
nextTick(() => {
adjustOtherTextareaHeight()
})
return
}
if (!newVal) {
resetForm()
}
}
)
watch(
normalizedQuestions,
() => {
syncAnswersWithQuestions()
if (activeQuestionIndex.value >= normalizedQuestions.value.length) {
activeQuestionIndex.value = Math.max(0, normalizedQuestions.value.length - 1)
}
nextTick(() => {
adjustOtherTextareaHeight()
})
},
{ immediate: true, deep: true }
)
const toggleSelect = (questionId, value) => {
if (isProcessing.value) return
const current = getSelected(questionId)
if (current.includes(value)) {
selectedValues.value[questionId] = current.filter((item) => item !== value)
} else {
selectedValues.value[questionId] = [...current, value]
}
nextTick(() => {
adjustOtherTextareaHeight()
})
}
const setSingle = (questionId, value) => {
if (isProcessing.value) return
selectedValues.value[questionId] = [value]
nextTick(() => {
adjustOtherTextareaHeight()
})
}
const isQuestionAnswered = (questionItem) => {
const selected = getSelected(questionItem.questionId)
if (selected.length === 0) return false
const other = String(otherTexts.value[questionItem.questionId] || '').trim()
if (questionItem.allowOther && isQuestionOtherSelected(questionItem)) {
return Boolean(other)
}
return true
}
const isSubmitDisabled = computed(() => {
if (isProcessing.value) return true
if (normalizedQuestions.value.length === 0) return true
return normalizedQuestions.value.some((questionItem) => !isQuestionAnswered(questionItem))
})
const isLastQuestion = computed(() => {
if (normalizedQuestions.value.length === 0) return true
return activeQuestionIndex.value >= normalizedQuestions.value.length - 1
})
const isCurrentQuestionAnswered = computed(() => {
if (!activeQuestion.value) return false
return isQuestionAnswered(activeQuestion.value)
})
const primaryButtonText = computed(() => (isLastQuestion.value ? '提交' : '下一项'))
const isPrimaryButtonDisabled = computed(() => {
if (isProcessing.value) return true
if (!activeQuestion.value) return true
if (isLastQuestion.value) {
return isSubmitDisabled.value
}
return !isCurrentQuestionAnswered.value
})
const buildQuestionAnswer = (questionItem) => {
const selected = getSelected(questionItem.questionId)
const other = String(otherTexts.value[questionItem.questionId] || '').trim()
if (questionItem.allowOther && isQuestionOtherSelected(questionItem)) {
const selectedWithoutOther = selected.filter((value) => value !== questionItem.otherOptionValue)
return {
type: 'other',
text: other,
selected: selectedWithoutOther
}
}
if (questionItem.multiSelect) {
return selected
}
return selected[0]
}
const buildAnswer = () => {
const answer = {}
normalizedQuestions.value.forEach((questionItem) => {
answer[questionItem.questionId] = buildQuestionAnswer(questionItem)
})
return answer
}
const handleSubmit = () => {
if (isSubmitDisabled.value) return
isProcessing.value = true
emit('submit', buildAnswer())
}
const handlePrimaryAction = () => {
if (isPrimaryButtonDisabled.value) return
if (isLastQuestion.value) {
handleSubmit()
return
}
setActiveQuestion(activeQuestionIndex.value + 1)
}
const handleCancel = () => {
if (isProcessing.value) return
emit('cancel')
}
</script>
<style scoped>
.approval-modal {
background: var(--gray-0);
border-radius: 12px 12px;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12);
margin: 0 auto 8px;
max-width: 800px;
min-width: 360px;
width: fit-content;
border: 1px solid var(--gray-200);
}
.approval-content {
padding: 16px 20px;
}
.question-tabs {
display: flex;
flex-wrap: nowrap;
justify-content: center;
gap: 8px;
margin-bottom: 14px;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
box-sizing: border-box;
overscroll-behavior-x: contain;
}
.tab-item {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
width: 36px;
height: 30px;
border: 1px solid var(--gray-200);
background: var(--gray-25);
color: var(--gray-700);
border-radius: 8px;
padding: 0;
font-size: 12px;
cursor: pointer;
}
.tab-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tab-item.active {
border-color: var(--main-color);
background: var(--main-50);
color: var(--main-700);
}
.tab-item.completed .tab-index {
color: var(--green-700);
border-color: var(--green-200);
background: var(--green-50);
}
.tab-index {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--gray-600);
}
.approval-header {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 12px;
}
.approval-header h4 {
margin: 0;
font-size: 15px;
font-weight: 500;
color: var(--gray-800);
text-align: left;
}
.approval-operation {
background: var(--gray-50);
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.approval-operation .label {
color: var(--gray-600);
font-weight: 500;
flex-shrink: 0;
}
.approval-operation .operation-text {
color: var(--gray-800);
word-break: break-word;
}
.question-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--gray-800);
font-size: 14px;
}
.option-item .recommended {
color: var(--main-color);
font-weight: 600;
}
.other-input {
margin-top: 10px;
}
.other-input textarea {
width: 100%;
border: 1px solid var(--gray-300);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
font-family: inherit;
outline: none;
resize: none;
overflow-y: hidden;
box-sizing: border-box;
}
.other-input textarea:focus {
border-color: var(--main-color);
}
.approval-actions {
display: flex;
gap: 10px;
padding: 12px 20px 16px;
}
.btn {
flex: 1;
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-reject {
background: var(--gray-100);
color: var(--gray-700);
}
.btn-reject:hover:not(:disabled) {
background: var(--gray-200);
}
.btn-approve {
background: var(--main-color);
color: var(--gray-0);
}
.btn-approve:hover:not(:disabled) {
background: var(--main-700);
}
.approval-processing {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
color: var(--gray-600);
font-size: 13px;
background: var(--gray-25);
border-top: 1px solid var(--gray-100);
}
.processing-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--gray-300);
border-top-color: var(--main-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.25s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(20px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(20px);
}
@media (max-width: 520px) {
.approval-modal {
width: calc(100vw - 12px);
min-width: 0;
}
.approval-content {
padding: 12px 16px;
}
.tab-item {
min-width: 30px;
width: 30px;
height: 26px;
padding: 0;
font-size: 11px;
}
.tab-index {
font-size: 10px;
}
.approval-header h4 {
font-size: 14px;
}
.approval-operation {
font-size: 12px;
padding: 8px 10px;
}
.approval-actions {
padding: 10px 16px 12px;
gap: 8px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
}
}
</style>
@@ -0,0 +1,82 @@
<template>
<div class="image-preview" v-if="imageData">
<div class="image-container">
<img
:src="`data:${imageData.mimeType};base64,${imageData.imageContent}`"
:alt="imageData.originalName"
class="preview-image"
/>
<button
class="remove-button"
type="button"
:aria-label="`移除图片 ${imageData.originalName || ''}`"
@click="handleRemove"
>
<X :size="14" />
</button>
</div>
</div>
</template>
<script setup>
import { X } from 'lucide-vue-next'
defineProps({
imageData: {
type: Object,
default: null
}
})
const emit = defineEmits(['remove'])
// 移除图片
const handleRemove = () => {
emit('remove')
}
</script>
<style lang="less" scoped>
.image-container {
position: relative;
display: inline-block;
overflow: hidden;
}
.preview-image {
width: 80px;
height: 80px;
object-fit: cover;
display: block;
border-radius: 8px;
border: 1px solid var(--gray-200);
}
.remove-button {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--gray-0);
background: var(--gray-900);
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
background: var(--gray-700);
}
&:active {
transform: scale(0.96);
}
}
</style>
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More