chore: import upstream snapshot with attribution
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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 |
|
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>
|
||||
@@ -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>
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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 || {} })
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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模块已处理权限验证和请求头,使用时无需再手动添加认证头
|
||||
*/
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 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="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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 MB;PDF 和图片可选解析为 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="请输入管理员UID(3-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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||