feat: 同步知识库与工作台相关改动

This commit is contained in:
Jackzhou
2026-08-24 10:40:15 +08:00
parent 8d0588827d
commit 4f33b036d1
50 changed files with 8034 additions and 273 deletions
+51 -3
View File
@@ -38,8 +38,32 @@ export function getSecretsStatus() {
}
// SSE 流式对话:后端 text/event-stream,每行 "data: {json}\n\n"
export async function streamChat(message, context, history, onChunk) {
export async function streamChat(message, context, history, handlers) {
const token = localStorage.getItem('token')
const onChunk = typeof handlers === 'function' ? handlers : handlers?.onChunk
const onMeta = typeof handlers === 'function' ? null : handlers?.onMeta
const onDone = typeof handlers === 'function' ? null : handlers?.onDone
const traceId = `chat-${Date.now()}`
// #region debug-point A:frontend-request
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
body: JSON.stringify({
sessionId: 'knowledge-chat-401',
runId: 'pre-fix',
hypothesisId: 'A',
location: 'frontend/src/api/ai.js:streamChat:request',
traceId,
msg: '[DEBUG] streamChat request',
data: {
hasToken: Boolean(token),
url: '/api/ai-chat/message',
messagePreview: String(message || '').slice(0, 80),
spaceKey: context?.knowledge_space_key || '',
},
ts: Date.now(),
}),
}).catch(() => {})
// #endregion
const resp = await fetch('/api/ai-chat/message', {
method: 'POST',
headers: {
@@ -51,12 +75,33 @@ export async function streamChat(message, context, history, onChunk) {
if (!resp.ok) {
let msg = 'LLM 请求失败'
let raw = ''
try {
const j = await resp.json()
raw = await resp.text()
const j = JSON.parse(raw)
msg = j.message || j.error || msg
} catch {
/* ignore */
}
// #region debug-point A:frontend-non200
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
body: JSON.stringify({
sessionId: 'knowledge-chat-401',
runId: 'pre-fix',
hypothesisId: 'A',
location: 'frontend/src/api/ai.js:streamChat:non200',
traceId,
msg: '[DEBUG] streamChat non-200 response',
data: {
status: resp.status,
statusText: resp.statusText,
bodyPreview: String(raw || '').slice(0, 240),
},
ts: Date.now(),
}),
}).catch(() => {})
// #endregion
throw new Error(msg)
}
@@ -84,10 +129,13 @@ export async function streamChat(message, context, history, onChunk) {
continue
}
if (obj.type === 'text') {
onChunk(obj.content)
onChunk?.(obj.content)
} else if (obj.type === 'meta') {
onMeta?.(obj)
} else if (obj.type === 'error') {
throw new Error(obj.message || 'LLM 请求失败')
} else if (obj.type === 'done') {
onDone?.()
return
}
}
+40
View File
@@ -1,5 +1,45 @@
import http from './http'
export function listKnowledgeSpaces() {
return http.get('/knowledge/spaces')
}
export function createKnowledgeSpace(data) {
return http.post('/knowledge/spaces', data)
}
export function updateKnowledgeSpace(id, data) {
return http.put(`/knowledge/spaces/${id}`, data)
}
export function deleteKnowledgeSpace(id) {
return http.delete(`/knowledge/spaces/${id}`)
}
export function listKnowledgeFaqs(params) {
return http.get('/knowledge/faqs', { params })
}
export function createKnowledgeFaq(data) {
return http.post('/knowledge/faqs', data)
}
export function updateKnowledgeFaq(id, data) {
return http.put(`/knowledge/faqs/${id}`, data)
}
export function deleteKnowledgeFaq(id) {
return http.delete(`/knowledge/faqs/${id}`)
}
export function rebuildKnowledgeIndex() {
return http.post('/knowledge/index/rebuild')
}
export function searchKnowledge(params) {
return http.get('/knowledge/search', { params })
}
export function knowledgeScan() {
return http.post('/knowledge/scan')
}
+5
View File
@@ -0,0 +1,5 @@
import http from './http'
export function getWorkbenchOverview() {
return http.get('/workbench/overview')
}
+37
View File
@@ -0,0 +1,37 @@
import http from './http'
export function listWorkerTasks(params) {
return http.get('/worker/tasks', { params })
}
export function getWorkerTaskDetail(id) {
return http.get(`/worker/tasks/${id}`)
}
export function updateWorkerTaskStatus(id, data) {
return http.put(`/worker/tasks/${id}/status`, data)
}
export function createWorkerTask(data) {
return http.post('/worker/tasks', data)
}
export function updateWorkerTask(id, data) {
return http.put(`/worker/tasks/${id}`, data)
}
export function deleteWorkerTask(id) {
return http.delete(`/worker/tasks/${id}`)
}
export function getWorkerArtifactDetail(id) {
return http.get(`/worker/artifacts/${id}`)
}
export function updateWorkerArtifactStatus(id, data) {
return http.put(`/worker/artifacts/${id}/status`, data)
}
export function executeWorkerTaskAction(id, data) {
return http.post(`/worker/tasks/${id}/actions`, data)
}
+44 -18
View File
@@ -2,8 +2,9 @@ export const topNavItems = [
{ key: 'apps', label: '01 专员工作区', title: '专员工作区', route: '/home' },
{ key: 'market', label: '02 专员市场', title: '专员市场', route: '/market', adminOnly: true },
{ key: 'studio', label: '03 工坊', title: '工坊', route: '/studio', adminOnly: true },
{ key: 'knowledge', label: '04 知识库', title: '知识库', route: '/knowledge-hub' },
{ key: 'console', label: '05 控制台', title: '控制台', route: '/console', adminOnly: true },
{ key: 'knowledge-app', label: '04 知识库应用', title: '知识库应用', route: '/knowledge-hub' },
{ key: 'knowledge-admin', label: '05 知识库管理', title: '知识库管理', route: '/knowledge-management', adminOnly: true },
{ key: 'console', label: '06 控制台', title: '控制台', route: '/console', adminOnly: true },
]
export const businessApps = [
@@ -136,7 +137,8 @@ export const businessApps = [
]
export const knowledgeLinks = [
{ label: '资料资产', route: '/knowledge-hub', caption: 'Assets / Evidence / Rules' },
{ label: '知识库应用', route: '/knowledge-hub', caption: 'Chat / Search / Upload' },
{ label: '知识库管理', route: '/knowledge-management', caption: 'Spaces / Docs / Chunks', adminOnly: true },
{ label: '产品知识', route: '/products', caption: '现有知识内容' },
{ label: '销售培训', route: '/courses', caption: '课程与训练' },
{ label: '公司介绍', route: '/company-train', caption: '组织基础资料' },
@@ -159,12 +161,18 @@ export const secondaryNavItems = {
{ label: 'DW 模板', route: '/studio' },
{ label: '发布记录', route: '/studio' },
],
knowledge: [
{ label: '资料资产', route: '/knowledge-hub' },
'knowledge-app': [
{ label: '对话工作台', route: '/knowledge-hub' },
{ label: '产品知识', route: '/products' },
{ label: '销售培训', route: '/courses' },
{ label: '公司介绍', route: '/company-train' },
],
'knowledge-admin': [
{ label: '知识空间', route: '/knowledge-management' },
{ label: '文档管理', route: '/knowledge-management' },
{ label: '审批发布', route: '/knowledge-management' },
{ label: '质量运营', route: '/knowledge-management' },
],
console: [
{ label: '连接器健康', route: '/console' },
{ label: '资源用量', route: '/console' },
@@ -598,18 +606,33 @@ const appWorkspaceData = {
const genericSurfaceContext = {
'/knowledge-hub': {
outputs: ['对象字典更新建议', '证据片段索引', '规则库摘要'],
artifacts: ['知识台摘要.md', '对象映射表.xlsx'],
evidence: ['资料资产 128', '证据片段 349', '规则条目 26'],
risks: ['两个对象映射待确认', '规则库缺少版本号'],
checkpoints: ['知识管理员复核'],
relatedObjects: ['Object: Contract', 'PolicyRule: 合规红线', 'Scenario: 销售培训'],
events: ['对象字典已同步', '新增 Evidence 12 条'],
outputs: ['知识问答结果', '上传文档摘要', '会话沉淀结果'],
artifacts: ['chat-session-brief.md'],
evidence: ['产品知识库', '培训资料库', '规则制度库'],
risks: ['当前引用明细仍以会话上下文展示为主'],
checkpoints: ['确认知识空间范围'],
relatedObjects: ['KnowledgeSpace', 'KnowledgeDocument', 'KnowledgeSession'],
events: ['进入知识库应用工作台', '已加载默认知识空间'],
bottomPanel: {
timeline: ['09:00 知识台加载完成', '09:15 规则同步', '09:45 对象映射更新'],
replay: ['回放节点 1: LoadAssets', '回放节点 2: RebuildEvidence', '回放节点 3: UpdateObjects'],
trace: ['connector.dms.loadAssets()', 'plugin.knowledge.extractEvidence()', 'plugin.object.syncDictionary()'],
logs: ['[INFO] assets loaded', '[INFO] evidence indexed', '[WARN] object mapping pending'],
timeline: ['09:00 知识工作台加载完成', '09:15 初始化默认会话'],
replay: ['回放节点 1: OpenWorkspace', '回放节点 2: PrepareContext'],
trace: ['knowledge.chat.open()', 'knowledge.chat.prepareContext()'],
logs: ['[INFO] knowledge application ready'],
},
},
'/knowledge-management': {
outputs: ['知识空间概览', '文档治理面板', '审批发布队列'],
artifacts: ['knowledge-ops-overview.md'],
evidence: ['资料文档', '知识源记录', '切片统计'],
risks: ['部分知识空间仍为逻辑划分,待后续独立落库'],
checkpoints: ['管理员确认上传与审批策略'],
relatedObjects: ['KnowledgeSpace', 'KnowledgeDocument', 'KnowledgeChunk'],
events: ['进入知识库管理台', '已加载资料与审批统计'],
bottomPanel: {
timeline: ['09:10 加载文档列表', '09:30 拉取审批队列'],
replay: ['回放节点 1: LoadDocuments', '回放节点 2: LoadAuditQueue'],
trace: ['knowledge.admin.listDocuments()', 'knowledge.admin.listSources()'],
logs: ['[INFO] knowledge management ready'],
},
},
'/market': {
@@ -661,11 +684,12 @@ const genericSurfaceContext = {
export function getWorkbenchSurface(path) {
if (path === '/knowledge-hub' || path.startsWith('/products') || path.startsWith('/courses') || path.startsWith('/company-train')) {
return 'knowledge'
return 'knowledge-app'
}
if (path === '/knowledge-management' || path.startsWith('/knowledge/')) return 'knowledge-admin'
if (path === '/market') return 'market'
if (path === '/studio') return 'studio'
if (path === '/console' || path.startsWith('/system') || path.startsWith('/knowledge/')) return 'console'
if (path === '/console' || path.startsWith('/system')) return 'console'
return 'apps'
}
@@ -744,6 +768,8 @@ export function getWorkspaceSnapshot(path, caseId = '') {
},
}
}
if (path.startsWith('/knowledge/')) return genericSurfaceContext['/knowledge-management']
if (path.startsWith('/system') || path === '/console') return genericSurfaceContext['/console']
return genericSurfaceContext[path] || genericSurfaceContext['/knowledge-hub']
}
+71 -4
View File
@@ -144,7 +144,7 @@
<span class="section-caption">固定入口</span>
</div>
<button
v-for="item in knowledgeLinks"
v-for="item in visibleKnowledgeLinks"
:key="item.route"
class="rail-link"
:class="{ active: route.path === item.route }"
@@ -243,6 +243,7 @@ import { Bell, Close, Search } from '@element-plus/icons-vue'
import { useAuthStore } from '@/store/auth'
import { useNotificationStore } from '@/store/notification'
import { useWorkbenchStore } from '@/store/workbench'
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
import { listSpecialists } from '@/api/specialist'
import {
businessApps,
@@ -263,6 +264,7 @@ const router = useRouter()
const auth = useAuthStore()
const notif = useNotificationStore()
const workbench = useWorkbenchStore()
const workerRuntime = useWorkerRuntimeStore()
const bottomTabs = [
{ key: 'timeline', label: 'Timeline' },
@@ -277,16 +279,32 @@ const avatarText = computed(() => username.value.charAt(0))
const unread = computed(() => notif.unread)
const activeSurface = computed(() => getWorkbenchSurface(route.path))
const appKey = computed(() => route.meta.workspaceKey || '')
const currentCases = computed(() => (appKey.value ? getAppCases(appKey.value) : []))
const runtimeCases = computed(() => workerRuntime.tasksBySpecialist[appKey.value] || [])
const currentCases = computed(() => {
if (runtimeCases.value.length) {
return runtimeCases.value.map(item => ({
id: String(item.id),
title: item.title,
owner: item.owner || '待指派',
priority: item.priority || 'P2',
due: formatDueAt(item.due_at),
summary: item.summary || '待补充事项摘要',
status: item.status || '待处理',
}))
}
return appKey.value ? getAppCases(appKey.value) : []
})
const selectedCaseId = computed(() => {
if (!appKey.value) return ''
return workbench.selectedCases[appKey.value] || getDefaultCaseId(appKey.value)
return workbench.selectedCases[appKey.value] || currentCases.value[0]?.id || getDefaultCaseId(appKey.value)
})
const remoteSpecialists = ref([])
const workspaceDescriptor = computed(() => getWorkspaceDescriptor(route.path, selectedCaseId.value))
const workspaceSnapshot = computed(() => getWorkspaceSnapshot(route.path, selectedCaseId.value))
const currentTaskDetail = computed(() => workerRuntime.detailByTask[String(selectedCaseId.value)] || null)
const visibleTopNavItems = computed(() => getVisibleTopNavItems(isAdmin.value))
const visibleBackendLinks = computed(() => getVisibleBackendLinks(isAdmin.value))
const visibleKnowledgeLinks = computed(() => knowledgeLinks.filter((item) => !item.adminOnly || isAdmin.value))
const genericApps = computed(() => {
const items = remoteSpecialists.value.filter(item => item.tier === 'generic')
return items.length ? items : getBusinessAppsByTier('generic')
@@ -311,7 +329,7 @@ const currentTitle = computed(() => {
})
const activeBottomItems = computed(() => {
const panel = workspaceSnapshot.value?.bottomPanel || {}
const panel = currentTaskDetail.value?.bottom_panel || workspaceSnapshot.value?.bottomPanel || {}
return panel[activeBottomTab.value] || []
})
@@ -327,6 +345,44 @@ onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
watch(
appKey,
async (key) => {
if (!key) return
try {
await workerRuntime.loadTasks(key)
} catch (error) {
console.warn('load worker tasks for shell failed', error)
}
},
{ immediate: true }
)
watch(
() => currentCases.value.map(item => item.id).join(','),
(ids) => {
if (!appKey.value || !ids) return
const selected = workbench.selectedCases[appKey.value]
if (!selected || !currentCases.value.some(item => item.id === selected)) {
workbench.setSelectedCase(appKey.value, currentCases.value[0]?.id || getDefaultCaseId(appKey.value))
}
},
{ immediate: true }
)
watch(
selectedCaseId,
async (id) => {
if (!id || !/^\d+$/.test(String(id))) return
try {
await workerRuntime.loadTaskDetail(id)
} catch (error) {
console.warn('load worker task detail for shell failed', error)
}
},
{ immediate: true }
)
watch(
() => route.path,
(path) => {
@@ -396,6 +452,17 @@ async function loadSpecialists() {
console.warn('load specialists for shell failed', error)
}
}
function formatDueAt(value) {
if (!value) return '待设置'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '待设置'
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
</script>
<style scoped>
+1
View File
@@ -13,6 +13,7 @@ const routes = [
children: [
{ path: 'home', name: 'Home', component: () => import('@/views/home/HomePage.vue') },
{ path: 'knowledge-hub', name: 'KnowledgeHub', component: () => import('@/views/workbench/KnowledgeHub.vue') },
{ path: 'knowledge-management', name: 'KnowledgeManagement', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/KnowledgeManagementPage.vue') },
{ path: 'market', name: 'Market', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/MarketPage.vue') },
{ path: 'studio', name: 'Studio', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/StudioPage.vue') },
{ path: 'console', name: 'Console', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/ConsolePage.vue') },
@@ -0,0 +1,153 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import {
createWorkerTask,
deleteWorkerTask,
executeWorkerTaskAction,
getWorkerArtifactDetail,
getWorkerTaskDetail,
listWorkerTasks,
updateWorkerArtifactStatus,
updateWorkerTask,
updateWorkerTaskStatus,
} from '@/api/worker'
export const useWorkerRuntimeStore = defineStore('workerRuntime', () => {
const tasksBySpecialist = ref({})
const detailByTask = ref({})
const artifactDetailById = ref({})
const loadingTasksBySpecialist = ref({})
const loadingDetailByTask = ref({})
async function loadTasks(specialistKey) {
if (!specialistKey) return []
loadingTasksBySpecialist.value = { ...loadingTasksBySpecialist.value, [specialistKey]: true }
try {
const res = await listWorkerTasks({ specialist_key: specialistKey })
const items = res?.data || []
tasksBySpecialist.value = { ...tasksBySpecialist.value, [specialistKey]: items }
return items
} finally {
loadingTasksBySpecialist.value = { ...loadingTasksBySpecialist.value, [specialistKey]: false }
}
}
async function loadTaskDetail(taskId) {
if (!taskId) return null
const key = String(taskId)
loadingDetailByTask.value = { ...loadingDetailByTask.value, [key]: true }
try {
const res = await getWorkerTaskDetail(taskId)
const detail = res?.data || null
detailByTask.value = { ...detailByTask.value, [key]: detail }
return detail
} finally {
loadingDetailByTask.value = { ...loadingDetailByTask.value, [key]: false }
}
}
async function executeAction(taskId, payload) {
const res = await executeWorkerTaskAction(taskId, payload)
const task = res?.data?.task
if (task?.specialist_key) {
await loadTasks(task.specialist_key)
}
await loadTaskDetail(taskId)
return res?.data || null
}
async function createTask(payload) {
const res = await createWorkerTask(payload)
const task = res?.data || null
if (task?.specialist_key) {
await loadTasks(task.specialist_key)
}
if (task?.id) {
await loadTaskDetail(task.id)
}
return task
}
async function updateTask(taskId, payload) {
const res = await updateWorkerTask(taskId, payload)
const task = res?.data || null
if (task?.specialist_key) {
await loadTasks(task.specialist_key)
}
if (task?.id) {
await loadTaskDetail(task.id)
}
return task
}
async function deleteTask(taskId) {
const existing = detailByTask.value[String(taskId)]?.task || null
const res = await deleteWorkerTask(taskId)
const specialistKey = res?.data?.specialist_key || existing?.specialist_key || ''
if (specialistKey) {
await loadTasks(specialistKey)
}
const nextDetail = { ...detailByTask.value }
delete nextDetail[String(taskId)]
detailByTask.value = nextDetail
return res?.data || null
}
async function loadArtifactDetail(artifactId) {
if (!artifactId) return null
const res = await getWorkerArtifactDetail(artifactId)
const detail = res?.data || null
artifactDetailById.value = {
...artifactDetailById.value,
[String(artifactId)]: detail,
}
return detail
}
async function updateArtifactStatus(artifactId, payload) {
const res = await updateWorkerArtifactStatus(artifactId, payload)
const task = res?.data?.task
if (task?.specialist_key) {
await loadTasks(task.specialist_key)
}
if (task?.id) {
await loadTaskDetail(task.id)
}
if (artifactId) {
await loadArtifactDetail(artifactId)
}
return res?.data || null
}
async function updateTaskStatus(taskId, payload) {
const res = await updateWorkerTaskStatus(taskId, payload)
const task = res?.data || null
if (task?.specialist_key) {
await loadTasks(task.specialist_key)
}
if (task?.id) {
await loadTaskDetail(task.id)
}
return task
}
const hasTasks = computed(() => Object.values(tasksBySpecialist.value).some(items => Array.isArray(items) && items.length))
return {
tasksBySpecialist,
detailByTask,
artifactDetailById,
loadingTasksBySpecialist,
loadingDetailByTask,
hasTasks,
loadTasks,
loadTaskDetail,
loadArtifactDetail,
executeAction,
createTask,
updateTask,
updateTaskStatus,
deleteTask,
updateArtifactStatus,
}
})
+18 -2
View File
@@ -53,11 +53,27 @@ function fmtTime(t) {
}
function typeLabel(t) {
return { exam_publish: '考试发布', exam_pass: '考试通过', position_set: '岗位设置' }[t] || '通知'
return {
exam_publish: '考试发布',
exam_pass: '考试通过',
position_set: '岗位设置',
worker_task_assign: '事项指派',
worker_artifact_review: '交付待确认',
worker_artifact_approved: '交付已确认',
worker_artifact_published: '交付已发布',
}[t] || '通知'
}
function typeTag(t) {
return { exam_publish: 'warning', exam_pass: 'success', position_set: 'primary' }[t] || 'info'
return {
exam_publish: 'warning',
exam_pass: 'success',
position_set: 'primary',
worker_task_assign: 'primary',
worker_artifact_review: 'warning',
worker_artifact_approved: 'success',
worker_artifact_published: 'success',
}[t] || 'info'
}
function openNotice(n) {
+80 -26
View File
@@ -10,7 +10,7 @@
</div>
<div class="hero-metrics">
<div class="metric-card">
<strong>11</strong>
<strong>{{ overview.todo_count }}</strong>
<span>我的待办</span>
</div>
<div class="metric-card">
@@ -31,15 +31,15 @@
<section class="summary-row">
<div class="summary-card">
<span class="summary-label">我的待办</span>
<strong>合同复核、方案评审、训练营日报、甩柜异常升级</strong>
<strong>{{ todoSummaryText }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">最近交付物</span>
<strong>redline 包、售前方案 v3、训练营周报、客户状态报告</strong>
<strong>{{ artifactSummaryText }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">高风险事项</span>
<strong>赔偿上限未对齐、连接器边界待确认、船司异常未关闭</strong>
<strong>{{ riskSummaryText }}</strong>
</div>
</section>
@@ -107,34 +107,55 @@
</section>
<section class="content-grid">
<div class="grid-card">
<div class="section-title">我的待办</div>
<button
v-for="item in todoItems"
:key="item.id"
class="home-link-item"
@click="openRoute(item.route)"
>
<div>
<div class="list-title">{{ item.title }}</div>
<div class="list-sub">{{ item.summary }}</div>
</div>
<el-tag size="small" effect="plain">{{ item.status }}</el-tag>
</button>
<el-empty v-if="!todoItems.length" description="当前没有待办事项" :image-size="72" />
</div>
<div class="grid-card">
<div class="section-title">最近交付物</div>
<div
<button
v-for="item in recentArtifacts"
:key="item.title"
class="list-item"
:key="item.id || item.title"
class="home-link-item"
@click="openRoute(item.route)"
>
<div>
<div class="list-title">{{ item.title }}</div>
<div class="list-sub">{{ item.sub }}</div>
</div>
<el-tag size="small" effect="plain">{{ item.tag }}</el-tag>
</div>
</button>
<el-empty v-if="!recentArtifacts.length" description="暂无最新交付物" :image-size="72" />
</div>
<div class="grid-card">
<div class="section-title">风险与阻塞</div>
<div
<button
v-for="item in risks"
:key="item.title"
class="risk-item"
:key="item.id || item.title"
class="home-link-item risk-item"
@click="openRoute(item.route)"
>
<div class="risk-dot" />
<div>
<div class="list-title">{{ item.title }}</div>
<div class="list-sub">{{ item.sub }}</div>
</div>
</div>
</button>
<el-empty v-if="!risks.length" description="暂无高风险事项" :image-size="72" />
</div>
<div v-if="visibleBackendLinks.length" class="grid-card">
@@ -159,6 +180,7 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'
import { getBusinessAppsByTier, getSpecialistStats, getVisibleBackendLinks } from '@/config/workbench'
import { getSpecialistSummary, listSpecialists } from '@/api/specialist'
import { getWorkbenchOverview } from '@/api/workbench'
const router = useRouter()
const auth = useAuthStore()
@@ -166,23 +188,23 @@ const visibleBackendLinks = computed(() => getVisibleBackendLinks(auth.isAdmin))
const remoteGenericApps = ref([])
const remoteIndustryApps = ref([])
const remoteStats = ref(null)
const remoteOverview = ref({
todo_count: 0,
todo_items: [],
recent_artifacts: [],
risk_items: [],
})
const genericApps = computed(() => remoteGenericApps.value.length ? remoteGenericApps.value : getBusinessAppsByTier('generic'))
const industryApps = computed(() => remoteIndustryApps.value.length ? remoteIndustryApps.value : getBusinessAppsByTier('industry'))
const specialistStats = computed(() => remoteStats.value || getSpecialistStats())
const recentArtifacts = [
{ title: '主协议 redline 包', sub: '合同审查 / 华东客户主协议修订', tag: '法务' },
{ title: '售前方案 v3', sub: '售前方案 / A 客户智能培训升级方案', tag: '售前' },
{ title: '训练营周报', sub: '培训交付 / 新人销售训练营第 3 周', tag: '培训' },
{ title: '客户状态报告', sub: '货代履约 / 美西航线本周履约看板', tag: '履约' },
]
const risks = [
{ title: '赔偿上限未对齐', sub: '合同审查事项等待法务经理确认' },
{ title: '连接器边界待确认', sub: '售前方案仍缺 OA 与 CRM 的回写范围' },
{ title: '船司甩柜异常未关闭', sub: '货代履约已升级运营经理处理' },
]
const overview = computed(() => remoteOverview.value || { todo_count: 0, todo_items: [], recent_artifacts: [], risk_items: [] })
const todoItems = computed(() => overview.value.todo_items || [])
const recentArtifacts = computed(() => overview.value.recent_artifacts || [])
const risks = computed(() => overview.value.risk_items || [])
const todoSummaryText = computed(() => buildSummaryText(todoItems.value.map(item => item.title), '当前没有待办事项'))
const artifactSummaryText = computed(() => buildSummaryText(recentArtifacts.value.map(item => item.title), '暂无最新交付物'))
const riskSummaryText = computed(() => buildSummaryText(risks.value.map(item => item.title), '暂无高风险事项'))
function normalizeSpecialist(item) {
return {
@@ -199,11 +221,22 @@ function normalizeSpecialist(item) {
}
}
function buildSummaryText(items, fallback) {
const filtered = items.filter(Boolean)
return filtered.length ? filtered.slice(0, 4).join('、') : fallback
}
function openRoute(route) {
if (!route) return
router.push(route)
}
onMounted(async () => {
try {
const [listRes, summaryRes] = await Promise.all([
const [listRes, summaryRes, overviewRes] = await Promise.all([
listSpecialists(),
getSpecialistSummary(),
getWorkbenchOverview(),
])
const items = listRes?.data || []
remoteGenericApps.value = items.filter(item => item.tier === 'generic').map(normalizeSpecialist)
@@ -211,6 +244,9 @@ onMounted(async () => {
if (summaryRes?.data) {
remoteStats.value = summaryRes.data
}
if (overviewRes?.data) {
remoteOverview.value = overviewRes.data
}
} catch (error) {
console.warn('load specialists failed', error)
}
@@ -418,6 +454,24 @@ onMounted(async () => {
border-bottom: 1px solid rgba(228, 231, 237, 0.72);
}
.home-link-item {
width: 100%;
appearance: none;
border: none;
background: transparent;
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(228, 231, 237, 0.72);
text-align: left;
cursor: pointer;
}
.home-link-item:hover .list-title {
color: #1f4d7e;
}
.list-item:last-child,
.backend-item:last-child,
.risk-item:last-child {
@@ -25,6 +25,31 @@
</div>
</div>
<div class="overview-tabs">
<el-button v-if="isAdmin" type="primary" @click="openCreateDialog">新建事项</el-button>
<el-button v-if="isAdmin && selectedTaskId" @click="openEditDialog">编辑事项</el-button>
<el-button v-if="isAdmin && selectedTaskId" @click="openAssignDialog">指派负责人</el-button>
<el-button v-if="isAdmin && selectedTaskId" type="danger" plain @click="removeTask">删除事项</el-button>
<el-button
v-if="isAdmin && selectedTaskId && currentTaskStatus !== '进行中'"
:loading="taskStatusLoading === '进行中'"
@click="setTaskLifecycle('进行中')"
>
开始推进
</el-button>
<el-button
v-if="isAdmin && selectedTaskId && currentTaskStatus !== '已完成'"
:loading="taskStatusLoading === '已完成'"
@click="setTaskLifecycle('已完成')"
>
标记完成
</el-button>
<el-button
v-if="isAdmin && selectedTaskId && currentTaskStatus !== '已归档'"
:loading="taskStatusLoading === '已归档'"
@click="setTaskLifecycle('已归档')"
>
归档事项
</el-button>
<span
v-for="tab in workspace.tabs"
:key="tab"
@@ -136,6 +161,19 @@
<span>审批:{{ item.approval }}</span>
<span>执行者:{{ item.operator }}</span>
</div>
<div class="action-footer">
<el-button
v-if="isAdmin"
type="primary"
size="small"
:loading="executingActionKey === item.actionKey"
@click="executeAction(item)"
>
执行动作
</el-button>
<span v-else class="action-readonly-tip">当前事项仅管理员可推进</span>
<span v-if="item.lastRunSummary" class="action-run-summary">{{ item.lastRunSummary }}</span>
</div>
</div>
</div>
</div>
@@ -158,6 +196,16 @@
<span>{{ item.meta }}</span>
<span>{{ item.derived }}</span>
</div>
<div class="action-footer">
<el-button
v-if="item.artifactId"
size="small"
@click="openArtifactDetail(item.artifactId)"
>
查看详情
</el-button>
<span v-if="item.sourceRefsText" class="action-run-summary">{{ item.sourceRefsText }}</span>
</div>
</div>
</div>
</div>
@@ -168,8 +216,8 @@
<div class="panel-title">任务看板</div>
<div class="task-grid">
<div
v-for="task in workspace.taskCards"
:key="task.title"
v-for="task in taskBoardItems"
:key="task.key || task.title"
class="task-card"
>
<div class="task-head">
@@ -228,27 +276,225 @@
</div>
</div>
</section>
<el-dialog v-model="taskDialogVisible" :title="taskDialogMode === 'create' ? '新建事项' : '编辑事项'" width="520px">
<el-form label-width="88px">
<el-form-item label="标题">
<el-input v-model="taskForm.title" placeholder="例如:A 客户售前方案补充说明" />
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="taskForm.summary" type="textarea" :rows="3" placeholder="补充这件事项要处理什么、交付什么" />
</el-form-item>
<el-form-item label="负责人">
<el-input v-model="taskForm.owner" placeholder="默认使用当前登录人" />
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="taskForm.priority" style="width: 100%">
<el-option label="P1" value="P1" />
<el-option label="P2" value="P2" />
<el-option label="P3" value="P3" />
</el-select>
</el-form-item>
<el-form-item label="截止时间">
<el-date-picker
v-model="taskForm.dueAt"
type="datetime"
value-format="YYYY-MM-DD HH:mm"
style="width: 100%"
placeholder="可选"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="taskDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingTask" @click="submitTaskForm">{{ taskDialogMode === 'create' ? '创建' : '保存' }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="assignDialogVisible" title="指派负责人" width="420px">
<el-form label-width="88px">
<el-form-item label="负责人">
<el-input v-model="assignOwner" placeholder="请输入负责人姓名" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="assigningOwner" @click="submitAssignOwner">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="artifactDialogVisible" title="交付物详情" width="760px">
<div v-if="selectedArtifact" class="artifact-detail">
<div class="artifact-detail-head">
<div>
<div class="artifact-title">{{ selectedArtifact.title }}</div>
<div class="artifact-meta-line">
<span>状态:{{ artifactStatusText(selectedArtifact.status) }}</span>
<span>类型:{{ selectedArtifact.artifact_type || 'text' }}</span>
<span>事项:{{ selectedArtifactTask?.title || '当前事项' }}</span>
</div>
</div>
<el-tag :type="tagTypeMap[artifactTone(selectedArtifact.status)] || 'info'" effect="plain">
{{ artifactStatusText(selectedArtifact.status) }}
</el-tag>
</div>
<div class="artifact-section">
<div class="artifact-section-title">内容</div>
<div class="artifact-content">{{ selectedArtifact.content_text || '当前交付物暂无正文内容。' }}</div>
</div>
<div class="artifact-section">
<div class="artifact-section-title">来源信源</div>
<div class="pill-list">
<span v-for="item in selectedArtifactSourceRefs" :key="item" class="info-pill">{{ item }}</span>
<span v-if="!selectedArtifactSourceRefs.length" class="artifact-empty">当前未记录来源信源</span>
</div>
</div>
<div class="artifact-section">
<div class="artifact-section-title">运行上下文</div>
<div class="artifact-meta-line">
<span>生成时间:{{ formatRunTime(selectedArtifact.created_at) || '待补充' }}</span>
<span>运行动作:{{ selectedArtifactRun?.action_title || '当前动作' }}</span>
</div>
</div>
</div>
<template #footer>
<el-button @click="artifactDialogVisible = false">关闭</el-button>
<el-button
v-if="isAdmin && selectedArtifact?.status === 'draft'"
type="primary"
:loading="artifactStatusLoading === 'ready'"
@click="transitionArtifact('ready')"
>
提交确认
</el-button>
<el-button
v-if="isAdmin && selectedArtifact?.status === 'ready'"
:loading="artifactStatusLoading === 'draft'"
@click="transitionArtifact('draft')"
>
退回修改
</el-button>
<el-button
v-if="isAdmin && selectedArtifact?.status === 'ready'"
type="primary"
:loading="artifactStatusLoading === 'approved'"
@click="transitionArtifact('approved')"
>
确认通过
</el-button>
<el-button
v-if="isAdmin && selectedArtifact?.status === 'approved'"
type="success"
:loading="artifactStatusLoading === 'published'"
@click="transitionArtifact('published')"
>
发布归档
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/store/auth'
import { useNotificationStore } from '@/store/notification'
import { useWorkbenchStore } from '@/store/workbench'
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
import { getSpecialistByKey } from '@/api/specialist'
import { getDefaultCaseId, getWorkspaceDescriptor, getWorkspaceSnapshot } from '@/config/workbench'
const route = useRoute()
const auth = useAuthStore()
const notificationStore = useNotificationStore()
const workbench = useWorkbenchStore()
const workerRuntime = useWorkerRuntimeStore()
const executingActionKey = ref('')
const taskDialogVisible = ref(false)
const taskDialogMode = ref('create')
const savingTask = ref(false)
const assignDialogVisible = ref(false)
const assigningOwner = ref(false)
const assignOwner = ref('')
const taskStatusLoading = ref('')
const artifactDialogVisible = ref(false)
const selectedArtifactId = ref('')
const artifactStatusLoading = ref('')
const taskForm = reactive({
title: '',
summary: '',
owner: '',
priority: 'P2',
dueAt: '',
})
const isAdmin = computed(() => auth.isAdmin)
const selectedTaskId = computed(() => (/^\d+$/.test(String(selectedCaseId.value)) ? String(selectedCaseId.value) : ''))
const currentTaskStatus = computed(() => currentTaskDetail.value?.task?.status || '')
const selectedCaseId = computed(() => {
const appKey = route.meta.workspaceKey || ''
if (!appKey) return ''
return workbench.selectedCases[appKey] || getDefaultCaseId(appKey)
return workbench.selectedCases[appKey] || currentTasks.value[0]?.id || getDefaultCaseId(appKey)
})
const currentTasks = computed(() => {
const appKey = route.meta.workspaceKey || ''
const items = workerRuntime.tasksBySpecialist[appKey] || []
return items.map(mapTaskToCase)
})
const currentTaskDetail = computed(() => workerRuntime.detailByTask[String(selectedCaseId.value)] || null)
const selectedArtifactDetail = computed(() => workerRuntime.artifactDetailById[String(selectedArtifactId.value)] || null)
const baseWorkspace = computed(() => getWorkspaceDescriptor(route.path) || null)
const baseSnapshot = computed(() => getWorkspaceSnapshot(route.path) || {})
const currentTask = computed(() => {
if (currentTaskDetail.value?.task) return mapTaskToCase(currentTaskDetail.value.task)
return currentTasks.value.find(item => item.id === String(selectedCaseId.value)) || null
})
const workspace = computed(() => {
if (!baseWorkspace.value) return null
const currentCase = currentTask.value || baseWorkspace.value.currentCase || null
return {
...baseWorkspace.value,
currentCase,
caseTitle: currentCase?.title || baseWorkspace.value.caseTitle,
subtitle: currentCase?.summary ? `${baseWorkspace.value.subtitle} 当前事项:${currentCase.summary}` : baseWorkspace.value.subtitle,
}
})
const snapshot = computed(() => {
const context = parseJSON(currentTaskDetail.value?.task?.context_json, {})
const latestArtifacts = (currentTaskDetail.value?.artifacts || []).slice(0, 3)
const latestRuns = currentTaskDetail.value?.runs || []
const fallbackBottom = baseSnapshot.value?.bottomPanel || {}
return {
...baseSnapshot.value,
evidence: context.evidence || baseSnapshot.value?.evidence || [],
outputs: latestArtifacts.length ? latestArtifacts.map(item => item.title) : (baseSnapshot.value?.outputs || []),
artifacts: latestArtifacts.length ? latestArtifacts.map(item => item.title) : (baseSnapshot.value?.artifacts || []),
risks: context.risks || baseSnapshot.value?.risks || [],
relatedObjects: context.relatedObjects || baseSnapshot.value?.relatedObjects || [],
bottomPanel: currentTaskDetail.value?.bottom_panel || fallbackBottom,
events: latestRuns.length
? latestRuns.slice(0, 3).map(run => {
const output = parseJSON(run.output_json, {})
return `${formatRunTime(run.started_at)} ${output.summary || run.action_title}`
})
: (baseSnapshot.value?.events || []),
}
})
const workspace = computed(() => getWorkspaceDescriptor(route.path, selectedCaseId.value))
const snapshot = computed(() => getWorkspaceSnapshot(route.path, selectedCaseId.value) || {})
const specialist = ref(null)
const taskBoardItems = computed(() => {
if (currentTasks.value.length) {
return currentTasks.value.map(item => ({
key: item.id,
title: item.title,
status: item.status,
detail: item.summary || '待补充事项摘要',
tone: toneFromStatus(item.status),
}))
}
return workspace.value?.taskCards || []
})
const capabilityItems = computed(() => {
if (!specialist.value) return []
@@ -353,15 +599,21 @@ const inputRecords = computed(() => {
const actionRecords = computed(() => {
const traces = snapshot.value?.bottomPanel?.trace || []
const outputs = snapshot.value?.outputs || []
const latestRuns = currentTaskDetail.value?.runs || []
const fallback = (workspace.value?.taskCards || []).map((task, index) => {
const tone = task.tone || 'info'
const actionTitle = task.title
const actionKey = normalizeActionKey(actionTitle)
const matchedRun = latestRuns.find(run => run.action_key === actionKey || run.action_title === actionTitle)
const output = parseJSON(matchedRun?.output_json, {})
return {
key: `action-${index}-${task.title}`,
title: task.title,
actionKey,
title: actionTitle,
type: getActionType(task.title),
triggerMode: specialist.value?.worker_type === 'adw' ? '自动 + 人工协同' : '人工触发 + AI辅助',
risk: getRiskLevel(tone),
status: task.status,
status: matchedRun ? statusText(matchedRun.status) : task.status,
tone,
detail: task.detail,
inputs: (snapshot.value?.evidence || []).slice(0, 2).join(' / ') || '待补充输入信源',
@@ -369,27 +621,44 @@ const actionRecords = computed(() => {
approval: tone === 'warning' || tone === 'danger' ? '需要确认' : '可直接执行',
operator: specialist.value?.worker_type === 'adw' ? 'ADW' : 'DW',
trace: traces[index] || '',
lastRunSummary: output.summary || '',
}
})
const parsed = parseStructuredRecords(specialist.value?.action_records_json).map((item, index) => ({
key: `action-${index}-${item.action_name || 'item'}`,
actionKey: item.action_key || normalizeActionKey(item.action_name || `动作 ${index + 1}`),
title: item.action_name || `动作 ${index + 1}`,
type: item.action_type || '执行',
triggerMode: item.trigger_mode || '未标注',
risk: riskText(item.risk_level),
status: statusText(item.status),
status: statusText(findLatestRunStatus(latestRuns, item.action_key || normalizeActionKey(item.action_name || `动作 ${index + 1}`), item.action_name) || item.status),
tone: actionTone(item.risk_level, item.approval_state),
detail: item.detail || `当前动作由 ${item.operator || 'DW'} 执行`,
inputs: listText(item.input_sources, '待补充输入信源'),
outputs: listText(item.expected_output, '待补充预期结果'),
approval: item.approval_state === 'approval_required' ? '需要确认' : '可直接执行',
operator: item.operator || (specialist.value?.worker_type === 'adw' ? 'ADW' : 'DW'),
lastRunSummary: findLatestRunSummary(latestRuns, item.action_key || normalizeActionKey(item.action_name || `动作 ${index + 1}`), item.action_name),
}))
return parsed.length ? parsed.slice(0, 6) : fallback
})
const resultRecords = computed(() => {
const currentStatus = workspace.value?.currentCase?.status || ''
const liveArtifacts = (currentTaskDetail.value?.artifacts || []).map((item, index) => ({
key: `artifact-${item.id}`,
artifactId: item.id,
title: item.title,
status: artifactStatusText(item.status),
tone: artifactTone(item.status),
detail: item.content_text || '当前交付物已生成,可继续确认、发布或引用。',
meta: `类型:${item.artifact_type || 'text'} · 创建时间:${formatRunTime(item.created_at)}`,
derived: `事项:${workspace.value?.currentCase?.title || '当前事项'}`,
sourceRefsText: parseJSON(item.source_refs_json, []).slice(0, 2).join(' / '),
}))
if (liveArtifacts.length) {
return liveArtifacts.slice(0, 6)
}
const fallback = [
...(snapshot.value?.outputs || []).map((item, index) => ({
key: `result-output-${index}-${item}`,
@@ -436,8 +705,233 @@ async function loadSpecialist() {
}
}
async function loadWorkerTasks() {
if (!route.meta.workspaceKey) return
try {
const tasks = await workerRuntime.loadTasks(route.meta.workspaceKey)
if (tasks.length) {
const selected = workbench.selectedCases[route.meta.workspaceKey]
const current = tasks.find(item => String(item.id) === String(selected))
if (!current) {
workbench.setSelectedCase(route.meta.workspaceKey, String(tasks[0].id))
}
}
} catch (error) {
console.warn('load worker tasks failed', error)
}
}
async function loadTaskDetail(taskId) {
if (!taskId || !/^\d+$/.test(String(taskId))) return
try {
await workerRuntime.loadTaskDetail(taskId)
} catch (error) {
console.warn('load worker task detail failed', error)
}
}
async function executeAction(item) {
if (!isAdmin.value) {
ElMessage.warning('只有管理员可以推进事项')
return
}
if (!selectedCaseId.value || !/^\d+$/.test(String(selectedCaseId.value))) {
ElMessage.warning('当前事项还未落库,暂时无法执行动作')
return
}
executingActionKey.value = item.actionKey
try {
await workerRuntime.executeAction(selectedCaseId.value, {
action_key: item.actionKey,
action_title: item.title,
action_type: item.type,
action_detail: item.detail,
inputs: splitText(item.inputs),
outputs: splitText(item.outputs),
approval: item.approval,
})
ElMessage.success('动作已执行,事项和交付物已更新')
} catch (error) {
ElMessage.error(error?.message || '动作执行失败')
} finally {
executingActionKey.value = ''
}
}
function openCreateDialog() {
if (!isAdmin.value) return
taskDialogMode.value = 'create'
taskForm.title = ''
taskForm.summary = workspace.value?.currentCase?.summary || ''
taskForm.owner = workspace.value?.currentCase?.owner || ''
taskForm.priority = 'P2'
taskForm.dueAt = ''
taskDialogVisible.value = true
}
function openEditDialog() {
if (!isAdmin.value || !currentTaskDetail.value?.task) return
taskDialogMode.value = 'edit'
taskForm.title = currentTaskDetail.value.task.title || ''
taskForm.summary = currentTaskDetail.value.task.summary || ''
taskForm.owner = currentTaskDetail.value.task.owner || ''
taskForm.priority = currentTaskDetail.value.task.priority || 'P2'
taskForm.dueAt = normalizeDueAtForInput(currentTaskDetail.value.task.due_at)
taskDialogVisible.value = true
}
function openAssignDialog() {
if (!isAdmin.value || !currentTaskDetail.value?.task) return
assignOwner.value = currentTaskDetail.value.task.owner || ''
assignDialogVisible.value = true
}
async function submitTaskForm() {
if (!isAdmin.value) {
ElMessage.warning('只有管理员可以维护事项')
return
}
if (!route.meta.workspaceKey) {
ElMessage.warning('当前专员未配置,暂时无法创建事项')
return
}
if (!taskForm.title.trim()) {
ElMessage.warning('请先填写事项标题')
return
}
savingTask.value = true
try {
const payload = {
specialist_key: route.meta.workspaceKey,
title: taskForm.title.trim(),
summary: taskForm.summary.trim(),
owner: taskForm.owner.trim(),
priority: taskForm.priority,
status: currentTaskDetail.value?.task?.status || workspace.value?.currentCase?.status || '待处理',
due_at: taskForm.dueAt || '',
context: {
evidence: snapshot.value?.evidence || [],
risks: snapshot.value?.risks || [],
relatedObjects: snapshot.value?.relatedObjects || [],
},
}
const task = taskDialogMode.value === 'create'
? await workerRuntime.createTask(payload)
: await workerRuntime.updateTask(selectedTaskId.value, payload)
if (task?.id) {
workbench.setSelectedCase(route.meta.workspaceKey, String(task.id))
}
taskDialogVisible.value = false
notificationStore.refresh()
ElMessage.success(taskDialogMode.value === 'create' ? '事项已创建' : '事项已更新')
} catch (error) {
ElMessage.error(error?.message || (taskDialogMode.value === 'create' ? '事项创建失败' : '事项更新失败'))
} finally {
savingTask.value = false
}
}
async function submitAssignOwner() {
if (!isAdmin.value || !selectedTaskId.value || !currentTaskDetail.value?.task) {
return
}
assigningOwner.value = true
try {
const current = currentTaskDetail.value.task
const updated = await workerRuntime.updateTask(selectedTaskId.value, {
specialist_key: current.specialist_key,
title: current.title,
summary: current.summary,
owner: assignOwner.value.trim(),
priority: current.priority,
status: current.status,
due_at: normalizeDueAtForInput(current.due_at),
context: parseJSON(current.context_json, {}),
})
if (updated?.id) {
workbench.setSelectedCase(route.meta.workspaceKey, String(updated.id))
}
assignDialogVisible.value = false
notificationStore.refresh()
ElMessage.success('负责人已更新')
} catch (error) {
ElMessage.error(error?.message || '负责人更新失败')
} finally {
assigningOwner.value = false
}
}
async function setTaskLifecycle(status) {
if (!isAdmin.value || !selectedTaskId.value) return
taskStatusLoading.value = status
try {
await workerRuntime.updateTaskStatus(selectedTaskId.value, { status })
ElMessage.success(`事项已更新为${status}`)
} catch (error) {
ElMessage.error(error?.message || '事项状态更新失败')
} finally {
taskStatusLoading.value = ''
}
}
async function openArtifactDetail(artifactId) {
selectedArtifactId.value = String(artifactId)
artifactDialogVisible.value = true
try {
await workerRuntime.loadArtifactDetail(artifactId)
} catch (error) {
ElMessage.error(error?.message || '交付物详情加载失败')
}
}
async function transitionArtifact(status) {
if (!isAdmin.value || !selectedArtifactId.value) return
artifactStatusLoading.value = status
try {
await workerRuntime.updateArtifactStatus(selectedArtifactId.value, { status })
notificationStore.refresh()
ElMessage.success(`交付物已更新为${artifactStatusText(status)}`)
} catch (error) {
ElMessage.error(error?.message || '交付物状态更新失败')
} finally {
artifactStatusLoading.value = ''
}
}
async function removeTask() {
if (!isAdmin.value || !selectedTaskId.value) return
try {
await ElMessageBox.confirm('删除后会同时移除该事项的运行记录和交付物,是否继续?', '删除事项确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await workerRuntime.deleteTask(selectedTaskId.value)
const remaining = workerRuntime.tasksBySpecialist[route.meta.workspaceKey] || []
const nextId = remaining[0] ? String(remaining[0].id) : ''
if (nextId) {
workbench.setSelectedCase(route.meta.workspaceKey, nextId)
await loadTaskDetail(nextId)
} else if (route.meta.workspaceKey) {
workbench.setSelectedCase(route.meta.workspaceKey, '')
}
ElMessage.success(res?.deleted ? '事项已删除' : '事项已删除')
} catch (error) {
ElMessage.error(error?.message || '事项删除失败')
}
}
onMounted(loadSpecialist)
watch(() => route.meta.workspaceKey, loadSpecialist)
onMounted(loadWorkerTasks)
watch(() => route.meta.workspaceKey, () => {
loadSpecialist()
loadWorkerTasks()
})
watch(selectedCaseId, loadTaskDetail, { immediate: true })
const statusTextMap = {
completed: '已完成',
@@ -530,6 +1024,114 @@ function resultTone(value = '') {
if (value === 'trial') return 'info'
return 'success'
}
function mapTaskToCase(task) {
return {
id: String(task.id),
title: task.title,
owner: task.owner || '待指派',
priority: task.priority || 'P2',
due: formatRunTime(task.due_at, true),
summary: task.summary || '待补充事项摘要',
status: task.status || '待处理',
}
}
const selectedArtifact = computed(() => {
const detail = selectedArtifactDetail.value?.artifact
if (detail) return detail
return (currentTaskDetail.value?.artifacts || []).find(item => String(item.id) === String(selectedArtifactId.value)) || null
})
const selectedArtifactTask = computed(() => {
return selectedArtifactDetail.value?.task || currentTaskDetail.value?.task || null
})
const selectedArtifactRun = computed(() => {
return selectedArtifactDetail.value?.run || null
})
const selectedArtifactSourceRefs = computed(() => {
return parseJSON(selectedArtifact.value?.source_refs_json, [])
})
function parseJSON(value, fallback) {
if (!value) return fallback
try {
return JSON.parse(value)
} catch {
return fallback
}
}
function formatRunTime(value, short = false) {
if (!value) return short ? '待设置' : ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return short ? '待设置' : ''
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return short ? `${month}-${day} ${hour}:${minute}` : `${month}-${day} ${hour}:${minute}`
}
function normalizeDueAtForInput(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
function toneFromStatus(value = '') {
if (containsText(value, '归档', '完成')) return 'success'
if (containsText(value, '确认', '审批')) return 'warning'
if (containsText(value, '阻塞', '异常')) return 'danger'
return 'primary'
}
function normalizeActionKey(value = '') {
return value
.trim()
.toLowerCase()
.replace(/[\s/\\·_]+/g, '-')
.replace(/-+/g, '-')
}
function findLatestRunStatus(runs = [], actionKey = '', actionTitle = '') {
return runs.find(run => run.action_key === actionKey || run.action_title === actionTitle)?.status || ''
}
function findLatestRunSummary(runs = [], actionKey = '', actionTitle = '') {
const run = runs.find(item => item.action_key === actionKey || item.action_title === actionTitle)
const output = parseJSON(run?.output_json, {})
return output.summary || ''
}
function artifactStatusText(value = '') {
const map = {
draft: '草案',
ready: '待确认',
approved: '已确认',
published: '已发布',
}
return map[value] || value || '已生成'
}
function artifactTone(value = '') {
if (value === 'published') return 'success'
if (value === 'approved') return 'success'
if (value === 'ready') return 'warning'
return 'primary'
}
function containsText(value = '', ...parts) {
return parts.some(part => value.includes(part))
}
</script>
<style scoped>
@@ -741,6 +1343,24 @@ function resultTone(value = '') {
color: #8a96a3;
}
.action-footer {
margin-top: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.action-run-summary {
font-size: 12px;
color: #63768a;
}
.action-readonly-tip {
font-size: 12px;
color: #8a96a3;
}
.stage-card {
padding: 14px;
border-radius: 16px;
@@ -1,132 +1,889 @@
<template>
<div class="surface-page">
<section class="hero panel">
<div>
<div class="eyebrow">Knowledge Hub</div>
<h2>知识、证据、规则、对象字典</h2>
<p>知识库作为固定入口存在,支撑所有数字员工的证据引用、规则约束和对象映射。</p>
</div>
<div class="hero-tags">
<span class="pill">Assets</span>
<span class="pill">Evidence</span>
<span class="pill">Rules</span>
<span class="pill">Objects</span>
<span class="pill">Scenarios</span>
</div>
</section>
<div class="knowledge-app-page">
<aside class="sidebar">
<button class="new-session-btn" @click="createSession">
<el-icon><Plus /></el-icon>
<span>新建会话</span>
</button>
<section class="grid">
<div class="panel">
<div class="panel-title">Evidence Explorer</div>
<div v-for="item in evidence" :key="item.title" class="list-item">
<div class="list-title">{{ item.title }}</div>
<div class="list-sub">{{ item.sub }}</div>
<section class="sidebar-section">
<div class="section-title">知识空间</div>
<button
v-for="space in knowledgeSpaces"
:key="space.id"
class="space-item"
:class="{ active: selectedSpaceId === space.id }"
@click="selectedSpaceId = space.id"
>
<span class="space-name">{{ space.name }}</span>
<small>{{ space.desc }}</small>
</button>
</section>
<section class="sidebar-section">
<div class="section-title">我的会话</div>
<button
v-for="session in sessions"
:key="session.id"
class="session-item"
:class="{ active: session.id === activeSessionId }"
@click="activeSessionId = session.id"
>
<span class="session-title">{{ session.title }}</span>
<small>{{ session.updatedAt }}</small>
</button>
</section>
</aside>
<main class="chat-stage">
<div ref="chatScrollRef" class="chat-scroll">
<div v-if="!activeMessages.length" class="empty-state">
<div class="eyebrow">Knowledge Application</div>
<h2>你好,我是知识库助手</h2>
<p>
基于本地知识库回答问题、总结文档、生成结果。你可以直接提问,也可以先上传文件再继续追问。
</p>
</div>
<div class="chat-layout" :class="{ 'has-messages': activeMessages.length }">
<div class="message-stream">
<div
v-for="message in activeMessages"
:key="message.id"
class="message-row"
:class="message.role"
>
<div class="avatar">{{ message.role === 'user' ? '我' : '知' }}</div>
<div class="bubble-wrap">
<div class="bubble">
<template v-if="message.content">
<div class="message-text">{{ message.content }}</div>
</template>
<template v-else-if="message.role === 'assistant' && streaming">
<div class="thinking-state">
<span class="thinking-spinner"></span>
<span class="thinking-text">{{ thinkingText }}</span>
</div>
</template>
</div>
<div v-if="message.meta?.spaceName || message.meta?.attachments?.length" class="message-meta">
<span v-if="message.meta?.spaceName">检索范围:{{ message.meta.spaceName }}</span>
<span v-if="message.meta?.attachments?.length">
附件:{{ message.meta.attachments.join('、') }}
</span>
</div>
</div>
</div>
</div>
<div class="chat-composer" :class="{ hero: !activeMessages.length }">
<div class="composer-toolbar">
<el-upload
action="/api/media/upload"
:data="uploadPayload"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
>
<el-button plain round>
<el-icon><Plus /></el-icon>
上传资料
</el-button>
</el-upload>
<span class="space-tip">当前检索范围:{{ selectedSpace.name }}</span>
</div>
<div class="attachment-row" v-if="attachments.length">
<span
v-for="file in attachments"
:key="file.id"
class="attachment-chip"
>
{{ file.name }}
</span>
</div>
<el-input
v-model="composer"
type="textarea"
:autosize="!activeMessages.length ? { minRows: 3, maxRows: 6 } : { minRows: 2, maxRows: 5 }"
resize="none"
:placeholder="!activeMessages.length ? '试着问:帮我总结销售培训资料里关于佣金规则的重点' : '继续追问,或让系统基于当前知识库生成结果'"
@keydown.enter.exact.prevent="submitMessage"
/>
<div class="composer-actions" :class="{ compact: activeMessages.length }">
<div class="quick-inline" :class="{ cards: !activeMessages.length }">
<button
v-for="action in quickActionList"
:key="action.id"
:class="activeMessages.length ? 'quick-link' : 'quick-card'"
@click="runQuickAction(action)"
>
{{ action.label }}
</button>
</div>
<el-button type="primary" round :loading="streaming" @click="submitMessage">
发送
</el-button>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">对象字典</div>
<div v-for="item in objects" :key="item" class="chip">{{ item }}</div>
</div>
</section>
</main>
<aside class="context-panel">
<section class="context-card">
<div class="context-title">当前检索范围</div>
<div class="context-name">{{ selectedSpace.name }}</div>
<div class="context-desc">{{ selectedSpace.desc }}</div>
</section>
<section class="context-card">
<div class="context-title">本次命中层级</div>
<div v-if="latestRouting.layer" class="tag-list">
<span class="tag">{{ layerLabel(latestRouting.layer) }}</span>
<span class="tag">{{ intentLabel(latestRouting.intent) }}</span>
</div>
<div v-else class="empty-tip">当前会话还没有完成路由判定</div>
</section>
<section class="context-card">
<div class="context-title">命中引用</div>
<div v-if="citationLoading" class="empty-tip">正在检索相关知识片段...</div>
<div v-else-if="latestCitations.length" class="citation-list">
<article
v-for="citation in latestCitations"
:key="citation.id"
class="citation-item"
>
<div class="citation-head">
<strong>{{ citation.title }}</strong>
<span>{{ citation.knowledge_space_name }}</span>
</div>
<p>{{ citation.snippet }}</p>
</article>
</div>
<div v-else class="empty-tip">当前会话还没有可展示的引用来源</div>
</section>
<section class="context-card">
<div class="context-title">本轮附件</div>
<div v-if="attachments.length" class="tag-list">
<span v-for="file in attachments" :key="file.id" class="tag">{{ file.name }}</span>
</div>
<div v-else class="empty-tip">暂未上传附件</div>
</section>
<section class="context-card">
<div class="context-title">推荐追问</div>
<button
v-for="prompt in followupPrompts"
:key="prompt"
class="followup-item"
@click="composer = prompt"
>
{{ prompt }}
</button>
</section>
</aside>
</div>
</template>
<script setup>
const evidence = [
{ title: 'e-2391 合同片段', sub: '已连接 合同审查 / 华东客户主协议修订' },
{ title: 'e-5199 调研会议纪要', sub: '已连接 售前方案 / A 客户升级方案' },
{ title: 'e-7012 船司异常邮件', sub: '已连接 货代履约 / 美西航线本周履约看板' },
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { quickAction, quickActions, streamChat } from '@/api/ai'
import { listKnowledgeSpaces } from '@/api/knowledge'
const fallbackKnowledgeSpaces = [
{ id: 'general', name: '通用知识库', desc: '面向企业通用制度、规则、案例与产品总览' },
{ id: 'product', name: '产品知识库', desc: '聚合产品资料、FAQ、对比说明与销售话术' },
{ id: 'training', name: '培训资料库', desc: '聚合培训课程、训练营资料与学习内容' },
{ id: 'policy', name: '规则制度库', desc: '聚合制度文件、审批规则与合规要求' },
]
const objects = ['Task', 'Artifact', 'Evidence', 'Scenario', 'PolicyRule', 'ConnectorBinding', 'Checkpoint']
const allKnowledgeSpace = {
id: 'all',
name: '全部知识库',
desc: '默认全局检索所有知识空间;主动选择左侧知识库时再收窄范围',
}
const followupPrompts = [
'帮我把这轮回答整理成摘要',
'把相关规则按条目列出来',
'生成一个培训提纲',
'告诉我还缺哪些材料',
]
const sessions = ref([])
const activeSessionId = ref('')
const selectedSpaceId = ref('all')
const composer = ref('')
const attachments = ref([])
const quickActionList = ref([])
const streaming = ref(false)
const citationLoading = ref(false)
const knowledgeSpaces = ref([...fallbackKnowledgeSpaces])
const chatScrollRef = ref(null)
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token') || ''
return { Authorization: `Bearer ${token}` }
})
const uploadPayload = computed(() => ({
knowledge_space_key: selectedSpaceId.value === 'all' ? 'general' : (selectedSpaceId.value || 'general'),
}))
const selectedSpace = computed(() => {
return knowledgeSpaces.value.find((item) => item.id === selectedSpaceId.value) || knowledgeSpaces.value[0] || allKnowledgeSpace
})
const activeSession = computed(() => {
return sessions.value.find((item) => item.id === activeSessionId.value) || null
})
const activeMessages = computed(() => activeSession.value?.messages || [])
const latestCitations = computed(() => {
const messages = [...activeMessages.value].reverse()
const latest = messages.find((item) => item.role === 'assistant' && item.meta?.citations?.length)
return latest?.meta?.citations || []
})
const thinkingText = computed(() => {
return citationLoading.value ? '正在检索知识库,请稍候...' : '正在整理回答,请稍候...'
})
const latestRouting = computed(() => {
const messages = [...activeMessages.value].reverse()
const latest = messages.find((item) => item.role === 'assistant' && (item.meta?.layer || item.meta?.intent))
return latest?.meta || {}
})
function createSession() {
const id = `session-${Date.now()}`
sessions.value.unshift({
id,
title: '新会话',
updatedAt: formatNow(),
messages: [],
})
activeSessionId.value = id
attachments.value = []
}
function scrollChatToBottom(smooth = true) {
nextTick(() => {
if (!chatScrollRef.value) return
chatScrollRef.value.scrollTo({
top: chatScrollRef.value.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
})
})
}
function ensureSession() {
if (!activeSession.value) createSession()
}
function updateSessionTitle(message) {
if (!activeSession.value) return
if (activeSession.value.title === '新会话') {
activeSession.value.title = message.slice(0, 16) || '新会话'
}
activeSession.value.updatedAt = formatNow()
}
function appendMessage(role, content, meta = {}) {
ensureSession()
const message = {
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
role,
content,
meta,
}
activeSession.value.messages.push(message)
scrollChatToBottom(false)
return message
}
function buildContext() {
return {
module: 'knowledge-application',
knowledge_space_key: selectedSpaceId.value,
knowledge_space: selectedSpace.value.name,
attachments: attachments.value.map((item) => item.name),
}
}
function buildHistory() {
return activeMessages.value.map((item) => ({
role: item.role === 'assistant' ? 'assistant' : 'user',
content: item.content,
}))
}
async function submitMessage() {
const message = composer.value.trim()
if (!message || streaming.value) return
const previousHistory = buildHistory()
appendMessage('user', message, {
spaceName: selectedSpace.value.name,
attachments: attachments.value.map((item) => item.name),
})
updateSessionTitle(message)
composer.value = ''
const assistantMessage = appendMessage('assistant', '', {
citations: [],
layer: '',
intent: '',
})
citationLoading.value = true
streaming.value = true
try {
await streamChat(message, buildContext(), previousHistory, {
onMeta(meta) {
assistantMessage.meta = {
...assistantMessage.meta,
citations: meta.citations || [],
layer: meta.layer || '',
intent: meta.intent || '',
}
citationLoading.value = false
},
onChunk(chunk) {
assistantMessage.content += chunk
},
onDone() {
citationLoading.value = false
},
})
} catch (error) {
assistantMessage.content = error.message || '知识库助手暂时不可用,请稍后重试。'
citationLoading.value = false
} finally {
streaming.value = false
updateSessionTitle(message)
}
}
async function runQuickAction(action) {
if (!action?.id || streaming.value) return
ensureSession()
appendMessage('user', `快捷动作:${action.label}`, {
spaceName: selectedSpace.value.name,
attachments: attachments.value.map((item) => item.name),
})
const assistantMessage = appendMessage('assistant', '', {
citations: [],
layer: '',
intent: '',
})
streaming.value = true
citationLoading.value = true
try {
const res = await quickAction(action.id, buildContext())
assistantMessage.meta = {
...assistantMessage.meta,
citations: res?.data?.citations || [],
layer: res?.data?.layer || '',
intent: res?.data?.intent || '',
}
assistantMessage.content = res?.data?.result || '已执行快捷动作,但暂未返回内容。'
} catch (error) {
assistantMessage.content = error.message || '快捷动作执行失败,请稍后重试。'
} finally {
citationLoading.value = false
streaming.value = false
activeSession.value.updatedAt = formatNow()
}
}
function handleUploadSuccess(response) {
const file = response?.data
if (!file) return
attachments.value.unshift({
id: file.id || `file-${Date.now()}`,
name: file.filename || file.original_name || '未命名资料',
})
ElMessage.success('资料上传成功,可继续在当前会话中提问')
}
function handleUploadError() {
ElMessage.error('资料上传失败')
}
function layerLabel(layer) {
return {
bert_classifier: 'Bert 分类器',
faq: 'FAQ 标准答案',
vector: '向量检索直返',
llm_fallback: 'LLM 兜底',
}[layer] || '未知层级'
}
function intentLabel(intent) {
return {
faq: 'FAQ 类问题',
document: '文档检索问题',
smalltalk: '闲聊拦截',
out_of_scope: '越域拦截',
invalid: '无效提问',
}[intent] || '未识别意图'
}
function formatNow() {
const now = new Date()
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
}
onMounted(async () => {
createSession()
try {
const spaceRes = await listKnowledgeSpaces()
const items = spaceRes?.data?.items || []
if (items.length) {
knowledgeSpaces.value = items.map((item) => ({
id: item.key,
name: item.name,
desc: item.description,
}))
knowledgeSpaces.value = [allKnowledgeSpace, ...knowledgeSpaces.value]
} else {
knowledgeSpaces.value = [allKnowledgeSpace, ...fallbackKnowledgeSpaces]
}
} catch (error) {
knowledgeSpaces.value = [allKnowledgeSpace, ...fallbackKnowledgeSpaces]
}
if (!knowledgeSpaces.value.some((item) => item.id === selectedSpaceId.value)) {
selectedSpaceId.value = 'all'
}
try {
const res = await quickActions()
quickActionList.value = res?.data?.actions || []
} catch (error) {
quickActionList.value = [
{ id: 'scenario', label: '客户情景演练' },
{ id: 'commission', label: '查询佣金/规则' },
{ id: 'compare', label: '产品对比' },
]
}
scrollChatToBottom(false)
})
watch(
() => activeMessages.value.map((item) => item.content).join('\n'),
() => {
scrollChatToBottom(false)
},
)
</script>
<style scoped>
.surface-page {
.knowledge-app-page {
display: grid;
grid-template-columns: 240px minmax(0, 1fr) 300px;
gap: 16px;
min-height: calc(100vh - 220px);
}
.sidebar,
.context-panel,
.chat-stage {
border-radius: 24px;
background: #fff;
border: 1px solid rgba(228, 231, 237, 0.9);
box-shadow: 0 18px 36px rgba(31, 35, 41, 0.05);
}
.sidebar,
.context-panel {
padding: 18px 16px;
}
.new-session-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
border-radius: 16px;
background: linear-gradient(135deg, #f5f8ff 0%, #eef6ff 100%);
color: #2b63d9;
font-size: 14px;
font-weight: 600;
padding: 12px 14px;
cursor: pointer;
}
.sidebar-section {
margin-top: 18px;
}
.section-title {
margin-bottom: 10px;
font-size: 12px;
letter-spacing: 0.3px;
color: #8a96a3;
}
.space-item,
.session-item,
.followup-item {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 12px 14px;
border: 1px solid rgba(228, 231, 237, 0.85);
border-radius: 16px;
background: #fff;
cursor: pointer;
text-align: left;
margin-bottom: 10px;
}
.space-item.active,
.session-item.active {
border-color: rgba(64, 158, 255, 0.4);
background: rgba(64, 158, 255, 0.06);
}
.space-name,
.session-title,
.context-name {
font-size: 14px;
font-weight: 600;
color: #1f2d3d;
}
.space-item small,
.session-item small,
.context-desc,
.empty-tip {
font-size: 12px;
line-height: 1.6;
color: #7a8694;
}
.chat-stage {
padding: 0;
overflow: hidden;
}
.chat-scroll {
height: 100%;
overflow: auto;
padding: 28px;
}
.empty-state {
margin: 0 auto 28px;
width: min(860px, 100%);
text-align: center;
}
.eyebrow {
font-size: 12px;
text-transform: uppercase;
color: #8a96a3;
}
.empty-state h2 {
margin: 10px 0 12px;
font-size: 40px;
color: #172533;
}
.empty-state p {
max-width: 680px;
margin: 0 auto 24px;
font-size: 15px;
line-height: 1.8;
color: #5f6b7a;
}
.chat-composer {
border: 1px solid rgba(228, 231, 237, 0.85);
border-radius: 24px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
padding: 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.chat-composer.hero {
width: min(860px, 100%);
margin: 0 auto;
}
.composer-toolbar,
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.composer-toolbar {
margin-bottom: 14px;
}
.space-tip {
font-size: 12px;
color: #7a8694;
}
.attachment-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0 0;
}
.attachment-chip,
.tag {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(64, 158, 255, 0.08);
color: #2b63d9;
font-size: 12px;
font-weight: 500;
}
.composer-actions {
margin-top: 16px;
}
.composer-actions.compact {
margin-top: 14px;
}
.quick-card,
.quick-link {
border: none;
cursor: pointer;
background: rgba(245, 247, 250, 0.92);
color: #4b5968;
}
.quick-card {
padding: 12px 14px;
border-radius: 16px;
font-size: 13px;
}
.quick-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.quick-inline.cards {
flex: 1;
}
.quick-link {
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
}
.chat-layout {
display: flex;
flex-direction: column;
gap: 20px;
width: min(860px, 100%);
margin: 0 auto;
}
.chat-layout.has-messages {
padding-top: 8px;
}
.message-stream {
display: flex;
flex-direction: column;
gap: 18px;
}
.message-row {
display: flex;
gap: 12px;
}
.message-row.user {
justify-content: flex-end;
}
.message-row.user .avatar {
order: 2;
background: #2b63d9;
color: #fff;
}
.message-row.user .bubble-wrap {
align-items: flex-end;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: #eef5ff;
color: #2b63d9;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.bubble-wrap {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(78%, 780px);
}
.bubble {
border-radius: 20px;
background: #f6f8fb;
padding: 14px 16px;
}
.message-row.user .bubble {
background: linear-gradient(135deg, #2b63d9 0%, #4f8fff 100%);
color: #fff;
}
.message-text {
white-space: pre-wrap;
line-height: 1.75;
font-size: 14px;
}
.thinking-state {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 24px;
color: #5f6b7a;
font-size: 14px;
}
.thinking-spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(43, 99, 217, 0.18);
border-top-color: #2b63d9;
animation: thinking-spin 0.8s linear infinite;
flex-shrink: 0;
}
.thinking-text {
line-height: 1.6;
}
@keyframes thinking-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.message-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #8a96a3;
}
.context-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
padding: 18px;
.context-card {
padding: 16px;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(228, 231, 237, 0.9);
box-shadow: 0 14px 28px rgba(31, 35, 41, 0.05);
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
border: 1px solid rgba(228, 231, 237, 0.85);
}
.hero {
display: flex;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
.context-title {
margin-bottom: 10px;
font-size: 12px;
letter-spacing: 0.4px;
text-transform: uppercase;
letter-spacing: 0.3px;
color: #8a96a3;
}
.hero h2 {
margin: 6px 0 10px;
font-size: 28px;
color: #172533;
}
.hero p {
font-size: 14px;
line-height: 1.7;
color: #526071;
max-width: 740px;
}
.hero-tags {
.citation-list {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 8px;
flex-direction: column;
gap: 10px;
}
.pill,
.chip {
display: inline-flex;
padding: 8px 12px;
border-radius: 999px;
background: rgba(64, 158, 255, 0.08);
color: var(--el-color-primary);
font-size: 12px;
font-weight: 600;
margin: 0 8px 8px 0;
.citation-item {
padding: 12px;
border-radius: 16px;
background: rgba(245, 247, 250, 0.88);
}
.grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 16px;
.citation-head {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.panel-title,
.list-title {
font-size: 15px;
font-weight: 700;
.citation-head strong {
font-size: 13px;
color: #1f2d3d;
}
.list-item {
padding: 12px 0;
border-bottom: 1px solid rgba(228, 231, 237, 0.8);
.citation-head span,
.citation-item p {
font-size: 12px;
line-height: 1.7;
color: #6b7785;
}
.list-item:last-child {
border-bottom: none;
padding-bottom: 0;
.citation-item p {
margin: 0;
}
.list-sub {
margin-top: 6px;
font-size: 13px;
color: #63768a;
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 1440px) {
.knowledge-app-page {
grid-template-columns: 220px minmax(0, 1fr) 280px;
}
}
@media (max-width: 1200px) {
.knowledge-app-page {
grid-template-columns: 1fr;
}
.sidebar,
.context-panel {
order: 2;
}
.chat-scroll {
padding: 20px;
}
}
</style>
File diff suppressed because it is too large Load Diff