feat: 同步知识库与工作台相关改动
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import http from './http'
|
||||
|
||||
export function getWorkbenchOverview() {
|
||||
return http.get('/workbench/overview')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user