From 244b3a58f7661e6b123875e652353acc132d4a10 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 15 May 2026 06:57:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=9B=B4=E6=96=B0=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E4=B8=AD=E5=BF=83=E3=80=81=E5=AE=A1=E8=AE=A1=E3=80=81?= =?UTF-8?q?=E6=94=BF=E7=AD=96=E5=88=B6=E5=BA=A6=E9=A1=B5=E9=9D=A2=E5=8F=8A?= =?UTF-8?q?=E5=AF=B9=E5=BA=94=E7=9A=84=E4=B8=9A=E5=8A=A1=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=89=8D=E7=AB=AF=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/views/ApprovalCenterView.vue | 52 +- web/src/views/AuditView.vue | 62 +- web/src/views/PoliciesView.vue | 171 ++++- web/src/views/scripts/ApprovalCenterView.js | 618 +++++++++++------- web/src/views/scripts/AuditView.js | 282 +++++++- web/src/views/scripts/PoliciesView.js | 270 ++++++-- .../views/scripts/TravelRequestDetailView.js | 12 +- 7 files changed, 1142 insertions(+), 325 deletions(-) diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue index bcbc9b0..93d4532 100644 --- a/web/src/views/ApprovalCenterView.vue +++ b/web/src/views/ApprovalCenterView.vue @@ -414,6 +414,11 @@
+ + - - - - - - ... - - -
- - diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index 7871213..2cd7216 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -88,7 +88,7 @@

Markdown 规则内容

-

当前展示版本:{{ selectedSkill.displayVersion }},保存后会生成新的版本快照。

+

当前展示版本:{{ selectedSkill.displayVersion }},规则说明与运行时 JSON 分开编辑,但保存时会一起进入版本快照。

+
+
+
+

运行时 JSON

+

编辑规则中心实际消费的 `config_json.runtime_rule`,保存时会同步写入配置并追加到 Markdown 版本快照。

+
+ +
+ +
+ + 模板 + {{ selectedSkill.ruleTemplateLabel }} + + + 模板键 + {{ selectedSkill.ruleTemplateKey || '未指定' }} + + + 运行时类型 + {{ selectedSkill.runtimeKind || 'policy_rule_draft' }} + +
+ + + +
+ JSON 必须是对象;保存后会同步写入资产配置,并以 `expense-rule` 代码块落到版本历史里。 + 当前展示版本:{{ selectedSkill.displayVersion }} +
+ +
+ 只读模式 +

当前账号没有规则编辑权限,运行时 JSON 仅可查看。

+
+
+
@@ -539,7 +593,7 @@
-
+

正在加载{{ activeTabLabel }}资产...

@@ -608,7 +662,7 @@
-
+
当前展示 {{ visibleSkills.length }} 条资产
diff --git a/web/src/views/PoliciesView.vue b/web/src/views/PoliciesView.vue index 3a4d8c8..6596d51 100644 --- a/web/src/views/PoliciesView.vue +++ b/web/src/views/PoliciesView.vue @@ -58,7 +58,7 @@
- +
@@ -91,21 +91,51 @@ - @@ -277,6 +307,115 @@ + + + + + String(item?.severity || '').trim().toLowerCase()) + .filter(Boolean) + + if (severities.includes('high')) { + return 'high' + } + if (severities.includes('medium')) { + return 'medium' + } + if (severities.includes('low')) { + return 'low' + } + } + + if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') { + return 'medium' + } + + return 'low' +} + +function resolveRiskItems(request) { + const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : [] + const items = riskFlags + .map((item) => { + const tone = resolveRiskTone([item], '') + const text = String(item?.message || item?.label || item?.reason || '').trim() + if (!text) { + return null + } + return { + text, + level: tone === 'high' ? '高' : tone === 'medium' ? '中' : '低', + tone, + icon: tone === 'high' ? 'mdi mdi-alert-circle' : tone === 'medium' ? 'mdi mdi-alert' : 'mdi mdi-shield-check' + } + }) + .filter(Boolean) + + if (items.length) { + return items + } + + const summary = String(request?.riskSummary || '').trim() + if (summary && summary !== '无') { + return summary.split(';').filter(Boolean).map((text) => ({ + text, + level: '中', + tone: 'medium', + icon: 'mdi mdi-alert' + })) + } + + return [ + { + text: 'AI验审已通过,当前未发现额外风险。', + level: '低', + tone: 'low', + icon: 'mdi mdi-shield-check' + } + ] +} + +function resolveAttachmentMeta(name) { + const normalized = String(name || '').trim() + const lowerName = normalized.toLowerCase() + if (lowerName.endsWith('.pdf')) { + return { icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' } + } + if (/\.(png|jpg|jpeg|webp|bmp)$/i.test(lowerName)) { + return { icon: 'mdi mdi-image', iconClass: 'img' } + } + return { icon: 'mdi mdi-file-document-outline', iconClass: 'file' } +} + +function buildAttachments(expenseItems) { + const seen = new Set() + const attachments = [] + + for (const item of Array.isArray(expenseItems) ? expenseItems : []) { + for (const fileName of Array.isArray(item?.attachments) ? item.attachments : []) { + const normalized = String(fileName || '').trim() + if (!normalized || seen.has(normalized)) { + continue + } + seen.add(normalized) + attachments.push({ + name: normalized, + size: '已识别', + ...resolveAttachmentMeta(normalized) + }) + } + } + + if (attachments.length) { + return attachments + } + + return [ + { + name: '当前无附件', + size: '待补充', + icon: 'mdi mdi-file-document-outline', + iconClass: 'miss', + missing: true + } + ] +} + +function resolveSlaMeta(submittedAt) { + const startAt = toDate(submittedAt) + if (!startAt) { + return { label: '待处理', tone: 'safe', urgent: false } + } + + const deadline = new Date(startAt.getTime() + DEFAULT_SLA_HOURS * 60 * 60 * 1000) + const diffMs = deadline.getTime() - Date.now() + if (diffMs <= 0) { + return { label: '已超时', tone: 'danger', urgent: true } + } + + const diffHours = diffMs / (60 * 60 * 1000) + const diffMinutes = Math.max(1, Math.ceil(diffMs / (60 * 1000))) + const label = diffHours >= 1 ? `${diffHours.toFixed(diffHours >= 10 ? 0 : 1)}h` : `${diffMinutes}m` + if (diffHours <= 2) { + return { label, tone: 'danger', urgent: true } + } + if (diffHours <= 8) { + return { label, tone: 'warning', urgent: false } + } + return { label, tone: 'safe', urgent: false } +} + +function buildHeroSummaryItems(request) { + return [ + { label: '单号', value: request.id || '-', icon: 'mdi mdi-pound-box-outline' }, + { label: '报销类型', value: request.typeLabel || '-', icon: 'mdi mdi-briefcase-outline' }, + { label: '业务地点', value: request.sceneTarget || '待补充', icon: 'mdi mdi-map-marker-outline' }, + { label: '发生时间', value: request.occurredDisplay || '待补充', icon: 'mdi mdi-calendar-range' }, + { label: '票据关联', value: request.attachmentSummary || '无', icon: 'mdi mdi-paperclip' }, + { label: '事由', value: request.title || '待补充', icon: 'mdi mdi-text-box-outline' } + ] +} + +function buildFlowItems(request) { + return Array.isArray(request?.progressSteps) + ? request.progressSteps.map((item) => ({ + label: item.label, + desc: item.current ? '当前处理节点' : item.done ? '已完成' : '待处理', + time: item.time, + icon: item.current ? 'mdi mdi-circle-slice-8' : item.done ? 'mdi mdi-check' : 'mdi mdi-circle-outline', + current: item.current, + pending: !item.done && !item.current + })) + : [] +} + +function canCurrentUserProcessRequest(request, currentUser) { + const node = String(request?.workflowNode || '').trim() + const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : [] + const currentName = String(currentUser?.name || '').trim() + const applicantName = String(request?.person || request?.employeeName || '').trim() + + if (currentName && applicantName && currentName === applicantName) { + return false + } + + if (currentUser?.isAdmin || roleCodes.includes('finance')) { + return node.includes('财务') + } + + return ( + node.includes('直属领导') + || node.includes('领导审批') + || node.includes('部门负责人') + || node.includes('负责人审批') + ) +} + +function buildApprovalRow(request) { + const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary) + const riskItems = resolveRiskItems(request) + const expenseItems = Array.isArray(request.expenseItems) ? request.expenseItems : [] + const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt) + const statusTone = slaMeta.urgent ? 'urgent' : 'pending' + + return { + ...request, + applicant: request.person, + avatar: String(request.person || '?').trim().slice(0, 1) || '?', + department: request.dept, + type: request.typeLabel, + amount: formatCurrency(request.amount), + time: request.applyTime, + risk: RISK_LABELS[riskTone] || RISK_LABELS.low, + riskTone, + sla: slaMeta.label, + slaTone: slaMeta.tone, + node: request.workflowNode || '审批中', + status: statusTone === 'urgent' ? '即将超时' : '待审批', + statusTone, + spotlight: riskTone === 'high' || statusTone === 'urgent', + heroSummaryItems: buildHeroSummaryItems(request), + summaryItems: buildHeroSummaryItems(request).slice(2), + progressSteps: Array.isArray(request.progressSteps) ? request.progressSteps : [], + expenseItems, + attachments: buildAttachments(expenseItems), + riskItems, + flowItems: buildFlowItems(request) + } +} export default { - name: 'ApprovalCenterView' , - setup(props, { emit }) { + name: 'ApprovalCenterView', + components: { + TableEmptyState + }, + setup() { + const { currentUser } = useSystemState() const activeTab = ref('全部待审') - const selectedRow = ref(null) + const selectedClaimId = ref('') const expandedExpenseId = ref(null) - const tabs = ['全部待审', '高风险', '即将超时', '已处理'] - const filters = ['法人主体', '费用类型', '风险等级', '金额区间', '所属部门'] + const listKeyword = ref('') + const rows = ref([]) + const loading = ref(false) + const error = ref('') - const rows = [ - { id: 'RE240712001', applicant: '李文静', avatar: '李', department: '市场部', type: '差旅报销', amount: '¥3,680', time: '07-12 09:20', risk: '中风险', riskTone: 'medium', sla: '4.2h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' }, - { id: 'RE240712002', applicant: '王志强', avatar: '王', department: '销售部', type: '招待费', amount: '¥1,280', time: '07-12 08:15', risk: '低风险', riskTone: 'low', sla: '8.5h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' }, - { id: 'RE240711098', applicant: '刘思雨', avatar: '刘', department: '市场部', type: '差旅报销', amount: '¥6,920', time: '07-11 18:46', risk: '高风险', riskTone: 'high', sla: '0.8h', slaTone: 'danger', node: '财务审批', status: '即将超时', statusTone: 'urgent', spotlight: true }, - { id: 'RE240711087', applicant: '陈晓琳', avatar: '陈', department: '行政部', type: '办公采购', amount: '¥860', time: '07-11 17:32', risk: '低风险', riskTone: 'low', sla: '6.1h', slaTone: 'safe', node: '预算校验', status: '待审批', statusTone: 'pending' }, - { id: 'RE240711076', applicant: '赵明', avatar: '赵', department: '研发中心', type: '其他费用', amount: '¥4,250', time: '07-11 15:10', risk: '中风险', riskTone: 'medium', sla: '2.4h', slaTone: 'warning', node: '部门负责人', status: '待审批', statusTone: 'pending' }, - { id: 'RE240711065', applicant: '孙楠', avatar: '孙', department: '财务部', type: '招待费', amount: '¥560', time: '07-11 13:42', risk: '低风险', riskTone: 'low', sla: '5.7h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' }, - { id: 'RE240711054', applicant: '周晓彤', avatar: '周', department: '市场部', type: '办公采购', amount: '¥2,150', time: '07-11 11:28', risk: '中风险', riskTone: 'medium', sla: '1.9h', slaTone: 'warning', node: '预算校验', status: '即将超时', statusTone: 'urgent' }, - { id: 'RE240711043', applicant: '吴磊', avatar: '吴', department: '销售部', type: '其他费用', amount: '¥980', time: '07-11 09:05', risk: '低风险', riskTone: 'low', sla: '7.3h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' } - ] - - const visibleRows = computed(() => { - if (activeTab.value === '全部待审') return rows - if (activeTab.value === '高风险') return rows.filter((row) => row.risk === '高风险') - if (activeTab.value === '即将超时') return rows.filter((row) => row.status === '即将超时') - return rows.slice(0, 3).map((row) => ({ ...row, status: '已处理', statusTone: 'done' })) + const selectedRow = computed({ + get() { + return rows.value.find((row) => row.claimId === selectedClaimId.value) || null + }, + set(value) { + selectedClaimId.value = value?.claimId || '' + expandedExpenseId.value = null + } }) - const approvalSteps = [ - { index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true }, - { index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true }, - { index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true }, - { index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true }, - { index: 5, label: '财务审批', time: '进行中', active: true, current: true }, - { index: 6, label: '归档入账', time: '待处理' } - ] + const visibleRows = computed(() => { + let filteredRows = rows.value - const summaryItems = [ - { label: '行程', value: '北京 → 上海', icon: 'mdi mdi-map-marker-path' }, - { label: '出差区间', value: '07-10 至 07-11', icon: 'mdi mdi-clock-outline' }, - { label: '票据关联', value: '8 条明细 / 7 份材料', icon: 'mdi mdi-file-document-multiple-outline' }, - { label: '成本归属', value: '市场部 · CC-MKT-01', icon: 'mdi mdi-account-group-outline' }, - { label: '支付方式', value: '企业垫付', icon: 'mdi mdi-credit-card-outline' } - ] + // 根据标签筛选 + if (activeTab.value === '高风险') { + filteredRows = filteredRows.filter((row) => row.riskTone === 'high') + } else if (activeTab.value === '即将超时') { + filteredRows = filteredRows.filter((row) => row.statusTone === 'urgent') + } else if (activeTab.value === '已处理') { + filteredRows = [] + } - const heroSummaryItems = computed(() => [ - { label: '单号', value: selectedRow.value?.id ?? '-', icon: 'mdi mdi-pound-box-outline' }, - { label: '报销类型', value: selectedRow.value?.type ?? '-', icon: 'mdi mdi-briefcase-outline' }, - ...summaryItems - ]) + // 根据搜索关键词筛选 + if (listKeyword.value.trim()) { + const keyword = listKeyword.value.trim().toLowerCase() + filteredRows = filteredRows.filter((row) => { + return ( + String(row.id || '').toLowerCase().includes(keyword) || + String(row.applicant || '').toLowerCase().includes(keyword) || + String(row.department || '').toLowerCase().includes(keyword) || + String(row.type || '').toLowerCase().includes(keyword) || + String(row.amount || '').toLowerCase().includes(keyword) + ) + }) + } + + return filteredRows + }) + const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0) + const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0) + const approvalEmptyState = computed(() => { + if (!rows.value.length) { + return { + eyebrow: '审批中心', + title: '当前没有待审批单据', + desc: '进入直属领导或财务审批节点的报销单会自动汇总到这里,后续可继续处理或跟踪。', + icon: 'mdi mdi-clipboard-check-outline', + actionLabel: null, + actionIcon: null, + tone: 'slate', + artLabel: 'QUEUE', + tips: ['当前仅展示你有权限处理的单据', '高风险和即将超时单据会优先高亮'] + } + } + + return { + eyebrow: '状态列表为空', + title: `“${activeTab.value}”里暂时没有单据`, + desc: activeTab.value === '已处理' + ? '当前视图还没有已处理审批数据,可以先回到全部待审继续处理。' + : '可以切换到其他状态查看,或返回全部待审列表继续处理。', + icon: activeTab.value === '已处理' ? 'mdi mdi-archive-clock-outline' : 'mdi mdi-view-list-outline', + actionLabel: '查看全部待审', + actionIcon: 'mdi mdi-format-list-bulleted', + tone: activeTab.value === '已处理' ? 'amber' : 'sky', + artLabel: activeTab.value === '已处理' ? 'DONE' : 'FILTER', + tips: ['分页与表格只在有数据时展示', '空态页面会保留当前页签上下文说明'] + } + }) + + const approvalSteps = computed(() => selectedRow.value?.progressSteps || []) + const summaryItems = computed(() => selectedRow.value?.summaryItems || []) + const heroSummaryItems = computed(() => selectedRow.value?.heroSummaryItems || []) + const expenseItems = computed(() => selectedRow.value?.expenseItems || []) + const expenseTotal = computed(() => selectedRow.value?.amount || formatCurrency(0)) + const uploadedExpenseCount = computed( + () => expenseItems.value.filter((item) => Array.isArray(item?.attachments) && item.attachments.length).length + ) + const attachments = computed(() => selectedRow.value?.attachments || []) + const riskItems = computed(() => selectedRow.value?.riskItems || []) + const flowItems = computed(() => selectedRow.value?.flowItems || []) const currentProgressRingMotion = { initial: { scale: 1, - opacity: 0.34, + opacity: 0.34 }, enter: { scale: [1, 1.42, 1.78], @@ -64,208 +359,68 @@ export default { repeatType: 'loop', repeatDelay: 0.85, ease: 'easeOut', - times: [0, 0.5, 1], - }, - }, + times: [0, 0.5, 1] + } + } } - const expenseItems = [ - { - id: 'flight-1', - time: '07-10 07:25', - dayLabel: '周三', - name: '机票', - category: '交通', - desc: '北京首都 → 上海虹桥', - detail: 'MU5103 往返经济舱,含行程单', - amount: '¥2,180', - status: '未超标', - tone: 'ok', - attachmentStatus: '已上传', - attachmentTone: 'ok', - attachmentHint: '电子行程单与机票发票齐全', - attachments: ['电子行程单.pdf', '机票发票.pdf'], - riskLabel: '低风险', - riskTone: 'low', - riskText: '票面信息与行程匹配。' - }, - { - id: 'taxi-1', - time: '07-10 10:35', - dayLabel: '周三', - name: '出租车', - category: '市内交通', - desc: '虹桥机场 → 静安酒店', - detail: '落地后前往酒店,含过路费', - amount: '¥86', - status: '未超标', - tone: 'ok', - attachmentStatus: '已上传', - attachmentTone: 'ok', - attachmentHint: '已上传 1 张发票', - attachments: ['出租车发票-0710-01.jpg'], - riskLabel: '中风险', - riskTone: 'medium', - riskText: '高峰加价较高,建议顺带核对上车点。' - }, - { - id: 'metro-1', - time: '07-10 18:20', - dayLabel: '周三', - name: '地铁', - category: '市内交通', - desc: '静安酒店 → 客户园区', - detail: '2 号线换乘,通勤交通', - amount: '¥12', - status: '未超标', - tone: 'ok', - attachmentStatus: '已上传', - attachmentTone: 'ok', - attachmentHint: '已上传电子票据', - attachments: ['地铁电子票据-0710.png'], - riskLabel: '低风险', - riskTone: 'low', - riskText: '路线与拜访行程一致。' - }, - { - id: 'taxi-2', - time: '07-11 08:40', - dayLabel: '周四', - name: '出租车', - category: '市内交通', - desc: '静安酒店 → 客户园区', - detail: '次日早会前往客户现场', - amount: '¥42', - status: '未超标', - tone: 'ok', - attachmentStatus: '未上传', - attachmentTone: 'missing', - attachmentHint: '缺少对应发票', - attachments: [], - riskLabel: '高风险', - riskTone: 'high', - riskText: '票据缺失,当前无法完成交通费核验。' - }, - { - id: 'taxi-3', - time: '07-11 20:55', - dayLabel: '周四', - name: '出租车', - category: '返程交通', - desc: '客户园区 → 虹桥机场', - detail: '夜间返程,触发超标校验', - amount: '¥136', - status: '超标 ¥28', - tone: 'bad', - attachmentStatus: '已上传', - attachmentTone: 'ok', - attachmentHint: '已上传 1 张发票', - attachments: ['出租车发票-0711-02.jpg'], - riskLabel: '中风险', - riskTone: 'medium', - riskText: '金额超差旅标准 ¥28,需补充业务说明。' - }, - { - id: 'hotel-1', - time: '07-10 至 07-11', - dayLabel: '2 晚', - name: '酒店', - category: '住宿', - desc: '上海静安商务酒店', - detail: '标准大床房 2 晚,含早餐', - amount: '¥2,480', - status: '未超标', - tone: 'ok', - attachmentStatus: '部分上传', - attachmentTone: 'partial', - attachmentHint: '发票已上传,入住清单缺失', - attachments: ['酒店发票.jpg'], - riskLabel: '高风险', - riskTone: 'high', - riskText: '缺少入住清单,住宿真实性待补证。' - }, - { - id: 'meal-1', - time: '07-10 至 07-11', - dayLabel: '2 天', - name: '餐补', - category: '补贴', - desc: '差旅餐补', - detail: '按差旅制度自动计算', - amount: '¥372', - status: '未超标', - tone: 'ok', - attachmentStatus: '免上传', - attachmentTone: 'neutral', - attachmentHint: '制度型补贴无需票据', - attachments: [], - riskLabel: '低风险', - riskTone: 'low', - riskText: '系统自动核算,无额外异常。' - }, - { - id: 'other-1', - time: '07-11 09:10', - dayLabel: '周四', - name: '其他', - category: '杂费', - desc: '行李寄存 / 打印费', - detail: '客户提案资料打印与寄存服务', - amount: '¥1,612', - status: '未超标', - tone: 'ok', - attachmentStatus: '已上传', - attachmentTone: 'ok', - attachmentHint: '已上传 2 份附件', - attachments: ['打印服务发票.jpg', '行李寄存凭证.jpg'], - riskLabel: '低风险', - riskTone: 'low', - riskText: '用途清晰,金额在授权范围内。' - } - ] + function showExpenseRisk(item) { + return ['medium', 'high'].includes(String(item?.riskTone || '').trim()) + } - const expenseTotal = '¥6,920' - - const uploadedExpenseCount = computed(() => expenseItems.filter((item) => item.attachments.length).length) - - const showExpenseRisk = (item) => item.riskTone === 'medium' || item.riskTone === 'high' - - const toggleExpenseAttachments = (id) => { + function toggleExpenseAttachments(id) { expandedExpenseId.value = expandedExpenseId.value === id ? null : id } - const attachments = [ - { name: '机票.pdf', size: '256 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' }, - { name: '酒店发票.jpg', size: '412 KB', icon: 'mdi mdi-image', iconClass: 'img' }, - { name: '行程单.pdf', size: '198 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' }, - { name: '出租车发票1.jpg', size: '128 KB', icon: 'mdi mdi-image', iconClass: 'img' }, - { name: '出租车发票2.jpg', size: '132 KB', icon: 'mdi mdi-image', iconClass: 'img' }, - { name: '酒店入住清单', size: '缺失', icon: 'mdi mdi-minus-circle', iconClass: 'miss', missing: true } - ] + function handleEmptyAction() { + if (!rows.value.length) { + void reload() + return + } - const riskItems = [ - { text: '酒店入住清单缺失', level: '高', tone: 'high', icon: 'mdi mdi-alert-circle' }, - { text: '1 笔出租车费用超差旅标准 ¥28', level: '中', tone: 'medium', icon: 'mdi mdi-alert' }, - { text: '发票抬头识别为个人,建议核对', level: '中', tone: 'medium', icon: 'mdi mdi-lightbulb-on' } - ] + activeTab.value = '全部待审' + } - const flowItems = [ - { label: '提交申请', desc: '刘思雨 提交申请', time: '07-11 08:46', icon: 'mdi mdi-check' }, - { label: '票据识别', desc: 'AI 自动识别完成', time: '07-11 08:48', icon: 'mdi mdi-check' }, - { label: '费用归类', desc: '费用归类完成', time: '07-11 08:49', icon: 'mdi mdi-check' }, - { label: '部门负责人审批', desc: '李文静 已通过', time: '07-11 11:28', icon: 'mdi mdi-check' }, - { label: '财务审批', desc: '张晓明 审批中', time: '进行中', icon: 'mdi mdi-circle-slice-8', current: true }, - { label: '归档入账', desc: '待处理', time: '-', icon: 'mdi mdi-circle-outline', pending: true } - ] + async function reload() { + loading.value = true + error.value = '' + + try { + const payload = await fetchExpenseClaims() + const mappedRows = Array.isArray(payload) + ? payload + .map((item) => mapExpenseClaimToRequest(item)) + .filter((item) => item.approvalKey === 'in_progress') + .filter((item) => canCurrentUserProcessRequest(item, currentUser.value)) + .map((item) => buildApprovalRow(item)) + : [] + rows.value = mappedRows + if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) { + selectedClaimId.value = '' + } + } catch (nextError) { + rows.value = [] + selectedClaimId.value = '' + error.value = nextError instanceof Error ? nextError.message : '审批中心加载失败。' + } finally { + loading.value = false + } + } + + void reload() return { activeTab, selectedRow, expandedExpenseId, + listKeyword, tabs, filters, rows, visibleRows, + showTable, + showEmpty, + approvalEmptyState, approvalSteps, summaryItems, heroSummaryItems, @@ -277,8 +432,11 @@ export default { toggleExpenseAttachments, attachments, riskItems, - flowItems + flowItems, + handleEmptyAction, + loading, + error, + reload } } } - diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index e8450fa..81e0d64 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -10,7 +10,8 @@ import { createAgentAssetVersion, fetchAgentAssetDetail, fetchAgentAssets, - fetchAgentRuns + fetchAgentRuns, + updateAgentAsset } from '../../services/agentAssets.js' import { isManagerUser } from '../../utils/accessControl.js' @@ -120,6 +121,8 @@ const SCENARIO_LABELS = { duplicate_expense: '重复报销', explain: '规则解释', invoice_anomaly: '票据异常', + travel_policy: '差旅制度', + travel_standard: '差旅标准', accounts_payable: '应付', accounts_receivable: '应收', approval_required: '需审批', @@ -216,10 +219,126 @@ const STATUS_OPTIONS = [ { value: 'disabled', label: '已停用' } ] +const EXPENSE_RULE_BLOCK_PATTERN = /```expense-rule\s*([\s\S]*?)\s*```/i + +const RULE_TEMPLATE_LABELS = { + travel_standard_v1: '差旅标准模板', + expense_amount_limit_v1: '金额上限模板', + attachment_requirement_v1: '附件要求模板', + general_policy_v1: '通用制度模板' +} + function normalizeText(value) { return String(value || '').trim() } +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function readConfigJson(value) { + if (isPlainObject(value?.configJson)) { + return value.configJson + } + if (isPlainObject(value?.config_json)) { + return value.config_json + } + return {} +} + +function cloneJsonObject(value) { + if (!isPlainObject(value)) { + return null + } + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return { ...value } + } +} + +function resolveRuleTemplateLabel(value) { + const templateKey = normalizeText(value) + return RULE_TEMPLATE_LABELS[templateKey] || templateKey || '未指定模板' +} + +function extractRuntimeRuleFromMarkdown(markdown) { + const match = String(markdown || '').match(EXPENSE_RULE_BLOCK_PATTERN) + if (!match) { + return null + } + + try { + const payload = JSON.parse(match[1]) + return isPlainObject(payload) ? payload : null + } catch { + return null + } +} + +function stripRuntimeRuleBlock(markdown) { + const text = String(markdown || '') + const stripped = text.replace(EXPENSE_RULE_BLOCK_PATTERN, '').replace(/\n{3,}/g, '\n\n').trim() + return stripped +} + +function stringifyRuntimeRule(runtimeRule) { + return JSON.stringify(isPlainObject(runtimeRule) ? runtimeRule : {}, null, 2) +} + +function parseRuntimeRuleText(runtimeRuleText) { + const text = normalizeText(runtimeRuleText) + if (!text) { + return null + } + + try { + const payload = JSON.parse(text) + return isPlainObject(payload) ? payload : null + } catch { + return null + } +} + +function buildDefaultRuntimeRule(source) { + const configJson = readConfigJson(source) + const scenarioItems = Array.isArray(source?.scenario_json) + ? source.scenario_json + : Array.isArray(source?.scenarioList) + ? source.scenarioList + : [] + const configRuntimeRule = cloneJsonObject(configJson.runtime_rule) + + return { + kind: normalizeText(configRuntimeRule?.kind || configJson.runtime_kind) || 'policy_rule_draft', + version: + typeof configRuntimeRule?.version === 'number' && Number.isFinite(configRuntimeRule.version) + ? configRuntimeRule.version + : 1, + template_key: + normalizeText(configRuntimeRule?.template_key || configJson.rule_template_key) || 'general_policy_v1', + rule_name: normalizeText(configRuntimeRule?.rule_name || source?.name) || '未命名规则', + scenario: + normalizeText(configRuntimeRule?.scenario || scenarioItems[0]) || 'expense', + review_required: + typeof configRuntimeRule?.review_required === 'boolean' ? configRuntimeRule.review_required : true + } +} + +function resolveRuntimeRuleForVersion(source, rawMarkdown, runtimeRuleFallback = null) { + return ( + cloneJsonObject(extractRuntimeRuleFromMarkdown(rawMarkdown)) || + cloneJsonObject(runtimeRuleFallback) || + buildDefaultRuntimeRule(source) + ) +} + +function buildMarkdownVersionContent(markdownContent, runtimeRule) { + const body = stripRuntimeRuleBlock(markdownContent) + const runtimeBlock = ['```expense-rule', stringifyRuntimeRule(runtimeRule), '```'].join('\n') + return body ? `${body}\n\n${runtimeBlock}` : runtimeBlock +} + function makeShort(value) { const text = normalizeText(value).replace(/\s+/g, '') if (!text) { @@ -273,16 +392,27 @@ function formatScenarioList(items) { .join(' / ') } -function buildHistory(recentVersions = []) { - return recentVersions.map((item) => ({ - version: item.version, - note: item.change_note || '无版本说明', - time: formatDateTime(item.created_at), - content: item.content, - contentType: item.content_type, - createdBy: item.created_by, - isCurrent: Boolean(item.is_current) - })) +function buildHistory(recentVersions = [], source) { + const currentRuntimeRule = cloneJsonObject(readConfigJson(source).runtime_rule) + + return recentVersions.map((item) => { + const rawContent = typeof item.content === 'string' ? item.content : '' + return { + version: item.version, + note: item.change_note || '无版本说明', + time: formatDateTime(item.created_at), + content: rawContent, + markdownContent: stripRuntimeRuleBlock(rawContent), + runtimeRule: resolveRuntimeRuleForVersion( + source, + rawContent, + item.is_current ? currentRuntimeRule : null + ), + contentType: item.content_type, + createdBy: item.created_by, + isCurrent: Boolean(item.is_current) + } + }) } function resolveTypeKey(assetType) { @@ -423,7 +553,15 @@ function buildListItem(asset) { function buildRuleFields(detail) { return [ { label: '规则编码', value: detail.code }, + { + label: '模板键', + value: normalizeText(detail.config_json?.rule_template_key) || '未指定' + }, { label: '业务域', value: resolveDomainLabel(detail.domain) }, + { + label: '运行时类型', + value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft' + }, { label: '适用场景', value: formatScenarioList(detail.scenario_json) }, { label: '当前版本', value: detail.current_version || '-' } ] @@ -555,7 +693,8 @@ function buildOutputRules(detail, typeKey, latestRun, latestCall) { if (typeKey === 'rules') { return [ - '规则 Markdown 保存后会生成新版本。', + '规则使用固定模板落 Markdown,并配套维护 runtime_rule JSON。', + '保存 Markdown 或 JSON 都会生成新版本快照。', '未审核通过的规则版本不能正式上线。', '版本切换当前只影响前端展示内容,不会直接回滚后端版本。' ] @@ -730,15 +869,26 @@ function buildDetailViewModel(detail, runs) { const typeKey = resolveTypeKey(detail.asset_type) const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null + const configJson = readConfigJson(detail) const statusMeta = resolveStatusMeta(detail.status) const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status) - const history = buildHistory(detail.recent_versions || []) + const history = buildHistory(detail.recent_versions || [], detail) const previewVersion = history.find((item) => item.isCurrent) || history[0] || null - const previewMarkdown = + const previewRawMarkdown = detail.current_version_content_type === 'markdown' ? String(previewVersion?.content ?? detail.current_version_content ?? '') : '' + const previewRuntimeRule = resolveRuntimeRuleForVersion( + detail, + previewRawMarkdown, + previewVersion?.runtimeRule || configJson.runtime_rule + ) + const previewMarkdown = stripRuntimeRuleBlock(previewRawMarkdown) const titles = DETAIL_TITLES[typeKey] + const previewChangeNote = previewVersion?.note || detail.current_version_change_note || '无版本说明' + const ruleTemplateKey = normalizeText(configJson.rule_template_key || previewRuntimeRule.template_key) + const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey) + const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft' return { id: detail.id, @@ -761,9 +911,16 @@ function buildDetailViewModel(detail, runs) { hitRate: buildRowMetric(detail, typeKey), updatedAt: formatDateTime(detail.updated_at), badgeTone: BADGE_TONES[typeKey], + configJson, + scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [], markdownContent: previewMarkdown, + runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule), + ruleTemplateKey, + ruleTemplateLabel, + runtimeKind, currentVersionContentType: detail.current_version_content_type, currentVersionChangeNote: detail.current_version_change_note || '无版本说明', + displayVersionChangeNote: previewChangeNote, reviewStatusLabel: reviewMeta.label, reviewStatusTone: reviewMeta.tone, reviewStatusValue: detail.latest_review?.review_status || '', @@ -1090,6 +1247,30 @@ export default { return currentUser.value?.name || currentUser.value?.username || 'system' } + function buildRuleConfigPayload(asset, runtimeRule) { + const configJson = { + ...readConfigJson(asset), + runtime_kind: normalizeText(runtimeRule?.kind) || asset.runtimeKind || 'policy_rule_draft', + runtime_rule: runtimeRule + } + const templateKey = normalizeText(runtimeRule?.template_key) || asset.ruleTemplateKey + if (templateKey) { + configJson.rule_template_key = templateKey + configJson.rule_template_label = resolveRuleTemplateLabel(templateKey) + } + return configJson + } + + async function persistRuleRuntimeConfig(asset, runtimeRule) { + await updateAgentAsset( + asset.id, + { + config_json: buildRuleConfigPayload(asset, runtimeRule) + }, + { actor: resolveActor() } + ) + } + async function loadRuns(options = {}) { if (runLoading.value && !options.force) { return @@ -1153,6 +1334,8 @@ export default { function openAssetDetail(asset) { selectedSkill.value = { ...asset, + configJson: {}, + scenarioList: [], fields: [], promptSections: [], outputRules: [], @@ -1161,7 +1344,12 @@ export default { tools: [], history: [], markdownContent: '', + runtimeRuleText: '', + ruleTemplateKey: '', + ruleTemplateLabel: '', + runtimeKind: 'policy_rule_draft', displayVersion: asset.version, + displayVersionChangeNote: '无版本说明', loading: true, reviewStatusLabel: '加载中', reviewStatusTone: 'draft' @@ -1194,9 +1382,17 @@ export default { } selectedSkill.value.displayVersion = versionSwitchTarget.value.version - if (typeof versionSwitchTarget.value.content === 'string') { - selectedSkill.value.markdownContent = versionSwitchTarget.value.content + selectedSkill.value.displayVersionChangeNote = versionSwitchTarget.value.note || '无版本说明' + if (typeof versionSwitchTarget.value.markdownContent === 'string') { + selectedSkill.value.markdownContent = versionSwitchTarget.value.markdownContent } + const runtimeRule = versionSwitchTarget.value.runtimeRule || buildDefaultRuntimeRule(selectedSkill.value) + selectedSkill.value.runtimeRuleText = stringifyRuntimeRule(runtimeRule) + selectedSkill.value.runtimeKind = + normalizeText(runtimeRule.kind) || selectedSkill.value.runtimeKind || 'policy_rule_draft' + selectedSkill.value.ruleTemplateKey = + normalizeText(runtimeRule.template_key) || selectedSkill.value.ruleTemplateKey + selectedSkill.value.ruleTemplateLabel = resolveRuleTemplateLabel(selectedSkill.value.ruleTemplateKey) versionSwitchTarget.value = null } @@ -1210,6 +1406,12 @@ export default { return } + const runtimeRule = parseRuntimeRuleText(selectedSkill.value.runtimeRuleText) + if (!runtimeRule) { + toast('运行时 JSON 必须是合法的对象。') + return + } + const nextVersion = incrementVersion(selectedSkill.value.currentVersion) actionState.value = 'save-markdown' @@ -1218,13 +1420,14 @@ export default { selectedSkill.value.id, { version: nextVersion, - content: selectedSkill.value.markdownContent, + content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule), content_type: 'markdown', - change_note: '通过任务规则中心保存 Markdown 规则内容。', + change_note: '通过任务规则中心保存 Markdown 规则内容,并同步运行时 JSON。', created_by: resolveActor() }, { actor: resolveActor() } ) + await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule) await refreshCurrentAssets() await loadSelectedAssetDetail(selectedSkill.value.id) toast(`规则 Markdown 已保存为 ${nextVersion}。`) @@ -1235,6 +1438,48 @@ export default { } } + async function saveRuleRuntimeJson() { + if (!selectedSkill.value || !selectedSkillIsRule.value || !canEditMarkdown.value || detailBusy.value) { + return + } + + if (!normalizeText(selectedSkill.value.markdownContent)) { + toast('规则 Markdown 模板不能为空。') + return + } + + const runtimeRule = parseRuntimeRuleText(selectedSkill.value.runtimeRuleText) + if (!runtimeRule) { + toast('运行时 JSON 必须是合法的对象。') + return + } + + const nextVersion = incrementVersion(selectedSkill.value.currentVersion) + actionState.value = 'save-runtime-json' + + try { + await createAgentAssetVersion( + selectedSkill.value.id, + { + version: nextVersion, + content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule), + content_type: 'markdown', + change_note: '通过任务规则中心保存运行时 JSON 配置。', + created_by: resolveActor() + }, + { actor: resolveActor() } + ) + await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule) + await refreshCurrentAssets() + await loadSelectedAssetDetail(selectedSkill.value.id) + toast(`规则 JSON 已保存为 ${nextVersion}。`) + } catch (error) { + toast(error?.message || '规则 JSON 保存失败,请稍后重试。') + } finally { + actionState.value = '' + } + } + async function reviewSelectedRule(reviewStatus) { if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { return @@ -1340,6 +1585,7 @@ export default { cancelVersionSwitch, confirmVersionSwitch, saveRuleMarkdown, + saveRuleRuntimeJson, reviewSelectedRule, activateSelectedRule, loadAssets diff --git a/web/src/views/scripts/PoliciesView.js b/web/src/views/scripts/PoliciesView.js index 10149da..a53ba11 100644 --- a/web/src/views/scripts/PoliciesView.js +++ b/web/src/views/scripts/PoliciesView.js @@ -3,14 +3,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' -import { - deleteKnowledgeDocument, - fetchKnowledgeDocument, - fetchKnowledgeDocumentBlob, - fetchKnowledgeLibrary, - fetchKnowledgeOnlyOfficeConfig, - uploadKnowledgeDocument -} from '../../services/knowledge.js' +import { + deleteKnowledgeDocument, + fetchKnowledgeDocument, + fetchKnowledgeDocumentBlob, + fetchLlmWikiDocumentDetail, + fetchKnowledgeLibrary, + fetchKnowledgeOnlyOfficeConfig, + syncKnowledgeDocumentToLlmWiki, + updateLlmWikiDocumentSummary, + uploadKnowledgeDocument +} from '../../services/knowledge.js' import { loadOnlyOfficeApi } from '../../services/onlyoffice.js' import { isManagerUser } from '../../utils/accessControl.js' import { @@ -97,6 +100,7 @@ export default { const uploadInput = ref(null) const uploading = ref(false) const deletingId = ref('') + const ingestingId = ref('') const deleteDialogOpen = ref(false) const deleteTargetDocument = ref(null) const previewLoading = ref(false) @@ -110,6 +114,13 @@ export default { const onlyOfficeReadyTimeoutId = ref(0) const currentPreviewPageIndex = ref(0) const previewDialogPanel = ref(null) + const llmWikiDialogOpen = ref(false) + const llmWikiDialogPanel = ref(null) + const llmWikiLoading = ref(false) + const llmWikiSaving = ref(false) + const llmWikiError = ref('') + const llmWikiDocument = ref(null) + const llmWikiSummaryDraft = ref('') const isAdmin = computed(() => isManagerUser(currentUser.value)) const uploadHint = computed(() => @@ -275,7 +286,13 @@ export default { closePreview() } } - } catch (error) { + if (options.preserveSelection && llmWikiDocument.value?.document_id) { + const exists = documents.value.some((doc) => doc.id === llmWikiDocument.value.document_id) + if (!exists) { + closeLlmWikiSummary() + } + } + } catch (error) { emit('summary-change', { totalDocuments: 0 }) toast(error.message || '知识库加载失败。') } finally { @@ -314,18 +331,167 @@ export default { } } - async function handleDownload(document) { - try { - const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment') - triggerFileDownload(blob, document.name) + async function handleDownload(document) { + try { + const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment') + triggerFileDownload(blob, document.name) } catch (error) { toast(error.message || '下载失败。') - } - } - - function triggerUpload() { - if (!isAdmin.value || uploading.value) { - return + } + } + + function patchDocumentState(documentId, patch) { + documents.value = documents.value.map((doc) => + doc.id === documentId ? { ...doc, ...patch } : doc + ) + + if (selectedDocument.value?.id === documentId) { + selectedDocument.value = { + ...selectedDocument.value, + ...patch + } + } + } + + function resolveIngestActionLabel(document) { + if (ingestingId.value === document.id) { + return '归纳中' + } + return Number(document?.stateCode || 0) === 3 ? '重新归纳' : '归纳' + } + + function resolveIngestActionTitle(document) { + const action = resolveIngestActionLabel(document) + if (action === '归纳中') { + return 'Hermes 正在将当前文档归纳到 LLM Wiki' + } + if (action === '重新归纳') { + return '重新使用 Hermes 归纳当前文档到 LLM Wiki' + } + return '使用 Hermes 归纳当前文档到 LLM Wiki' + } + + function canViewLlmWiki(document) { + return isAdmin.value && Number(document?.stateCode || 0) === 3 + } + + function resolveViewLlmWikiTitle(document) { + if (!isAdmin.value) { + return '仅管理员可查看 LLM Wiki 归纳内容' + } + if (Number(document?.stateCode || 0) === 2) { + return 'Hermes 正在归纳当前文档,完成后可查看 LLM Wiki 知识总结' + } + if (Number(document?.stateCode || 0) === 4) { + return '当前文档上次归纳失败,请重新归纳后再查看' + } + if (Number(document?.stateCode || 0) !== 3) { + return '文档尚未完成归纳,暂无可查看的 LLM Wiki 知识总结' + } + return '查看并编辑当前文档的 LLM Wiki 归纳内容' + } + + async function handleManualIngest(document) { + if (!isAdmin.value || ingestingId.value || !document?.id) { + return + } + + ingestingId.value = document.id + patchDocumentState(document.id, { + stateCode: 2, + state: '正归纳', + stateTone: 'warning' + }) + + try { + const payload = await syncKnowledgeDocumentToLlmWiki({ + folder: document.folder, + documentId: document.id + }) + await loadLibrary({ preserveSelection: true }) + if (selectedDocument.value?.id === document.id) { + await selectDocument(document.id) + } + toast(payload.summary || 'Hermes 已完成文档归纳。') + } catch (error) { + patchDocumentState(document.id, { + stateCode: 4, + state: '归纳失败', + stateTone: 'danger' + }) + toast(error.message || 'Hermes 归纳文档失败。') + } finally { + ingestingId.value = '' + } + } + + async function openLlmWikiSummary(document) { + if (!canViewLlmWiki(document) || llmWikiLoading.value || !document?.id) { + return + } + + llmWikiDialogOpen.value = true + llmWikiLoading.value = true + llmWikiError.value = '' + llmWikiDocument.value = null + llmWikiSummaryDraft.value = '' + + try { + const payload = await fetchLlmWikiDocumentDetail(document.id) + llmWikiDocument.value = payload + llmWikiSummaryDraft.value = payload.knowledge_summary_markdown || '' + await nextTick() + llmWikiDialogPanel.value?.focus?.() + } catch (error) { + llmWikiError.value = error.message || 'LLM Wiki 归纳内容加载失败。' + toast(llmWikiError.value) + } finally { + llmWikiLoading.value = false + } + } + + function closeLlmWikiSummary() { + if (llmWikiSaving.value) { + return + } + + llmWikiDialogOpen.value = false + llmWikiLoading.value = false + llmWikiError.value = '' + llmWikiDocument.value = null + llmWikiSummaryDraft.value = '' + } + + async function saveLlmWikiSummary() { + if (!isAdmin.value || !llmWikiDocument.value?.document_id || llmWikiSaving.value) { + return + } + + const summaryText = String(llmWikiSummaryDraft.value || '').trim() + if (!summaryText) { + toast('知识总结不能为空。') + return + } + + llmWikiSaving.value = true + try { + const payload = await updateLlmWikiDocumentSummary(llmWikiDocument.value.document_id, { + knowledge_summary_markdown: summaryText + }) + llmWikiDocument.value = payload + llmWikiError.value = '' + llmWikiSummaryDraft.value = payload.knowledge_summary_markdown || summaryText + toast('LLM Wiki 知识总结已保存。') + } catch (error) { + toast(error.message || 'LLM Wiki 知识总结保存失败。') + } finally { + llmWikiSaving.value = false + } + } + + function triggerUpload() { + if (!isAdmin.value || uploading.value) { + return } uploadInput.value?.click() } @@ -403,6 +569,9 @@ export default { if (selectedDocument.value?.id === document.id) { closePreview() } + if (llmWikiDocument.value?.document_id === document.id) { + closeLlmWikiSummary() + } await loadLibrary() toast('知识库文件已删除。') } catch (error) { @@ -431,6 +600,10 @@ export default { } function handleWindowKeydown(event) { + if (event.key === 'Escape' && llmWikiDialogOpen.value) { + closeLlmWikiSummary() + return + } if (event.key === 'Escape' && selectedDocument.value) { closePreview() } @@ -451,14 +624,21 @@ export default { watch(activeFolder, () => { closePreview() + closeLlmWikiSummary() }) watch( - () => previewLayoutState.value.isPreviewModalOpen, - async (isPreviewModalOpen) => { - setBodyScrollLocked(isPreviewModalOpen) + () => previewLayoutState.value.isPreviewModalOpen || llmWikiDialogOpen.value, + async (isAnyOverlayOpen) => { + setBodyScrollLocked(isAnyOverlayOpen) - if (isPreviewModalOpen) { + if (llmWikiDialogOpen.value) { + await nextTick() + llmWikiDialogPanel.value?.focus?.() + return + } + + if (previewLayoutState.value.isPreviewModalOpen) { await nextTick() previewDialogPanel.value?.focus?.() } @@ -483,6 +663,8 @@ export default { changePageSize, closePreview, closeDeleteDialog, + closeLlmWikiSummary, + canViewLlmWiki, confirmDeleteDocument, excelPreviewTable, currentPage, @@ -490,14 +672,24 @@ export default { deleteDialogOpen, deleteTargetDocument, deletingId, - documentSearch, - filteredFolders, - handleDelete, - handleDownload, - handleDrop, - handleFileInput, - isAdmin, - loading, + documentSearch, + filteredFolders, + handleDelete, + handleDownload, + handleDrop, + handleFileInput, + handleManualIngest, + ingestingId, + isAdmin, + llmWikiDialogOpen, + llmWikiDialogPanel, + llmWikiDocument, + llmWikiError, + llmWikiLoading, + llmWikiSaving, + llmWikiSummaryDraft, + loading, + openLlmWikiSummary, pageSize, pageSizeOpen, pageSizes, @@ -515,12 +707,16 @@ export default { shouldRenderOnlyOffice, shouldRenderOnlyOfficeHostNode, selectDocument, - selectPreviewPage, - selectedDocument, - totalCount, - totalPages, - triggerUpload, - uploadHint, + selectPreviewPage, + selectedDocument, + resolveIngestActionLabel, + resolveIngestActionTitle, + resolveViewLlmWikiTitle, + saveLlmWikiSummary, + totalCount, + totalPages, + triggerUpload, + uploadHint, uploadInput, uploading, visibleDocuments diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index e1d31e9..0bce47d 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -1093,8 +1093,16 @@ export default { submitBusy.value = true try { - await submitExpenseClaim(request.value.claimId) - toast(`${request.value.id} 已提交审批。`) + const payload = await submitExpenseClaim(request.value.claimId) + const claimStatus = String(payload?.status || '').trim().toLowerCase() + const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim() + if (claimStatus === 'submitted') { + toast(`${request.value.id} 已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) + } else if (claimStatus === 'supplement') { + toast(`${request.value.id} AI验审未通过,已转待补充。`) + } else { + toast(`${request.value.id} 提交结果已更新。`) + } emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '提交审批失败,请稍后重试。')
文件名称{{ doc.version }} {{ doc.state }} {{ doc.owner }} -
- - +
+
+ + + +