@@ -170,7 +190,13 @@ - + + @@ -408,6 +434,15 @@ {{ card.label }} {{ card.title }} + + + {{ tag }} + + {{ card.risk }} @@ -655,6 +690,68 @@ + + + + + + + {{ riskOverrideIndexLabel }} + + + + + + + {{ currentSubmitRiskWarning.label }} + {{ currentSubmitRiskWarning.title }} + + {{ currentSubmitRiskWarning.risk }} + + + {{ tag }} + + + + + + + + diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 0726335..65baad1 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -26,1628 +26,38 @@ import { import { loadOnlyOfficeApi } from '../../services/onlyoffice.js' import { isFinanceUser, isManagerUser } from '../../utils/accessControl.js' import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js' - -const RULE_TABLE_COLUMNS = { - name: '规则名称', - category: '业务域', - owner: '负责人', - scope: '适用场景', - version: '修改次数', - metric: '修改人' -} - -const TYPE_META = { - rules: { - assetType: 'rule', - label: '规则', - typeLabel: '规则', - tableColumns: RULE_TABLE_COLUMNS - }, - skills: { - assetType: 'skill', - label: '技能', - typeLabel: '技能', - createButtonLabel: '技能已接入', - hintText: '技能页签已接到真实资产 API,可查看输入、输出、依赖和场景信息。', - searchPlaceholder: '搜索技能名称、编码或负责人', - showMetricColumn: false, - tableColumns: { - name: '技能名称', - category: '业务域', - owner: '负责人', - scope: '适用场景', - runtime: '输入摘要', - version: '当前版本', - metric: '' - } - }, - mcp: { - assetType: 'mcp', - label: 'MCP', - typeLabel: 'MCP', - createButtonLabel: 'MCP 已接入', - hintText: 'MCP 页签已接到真实资产 API,可查看服务地址、鉴权方式、超时和降级策略。', - searchPlaceholder: '搜索 MCP 名称、编码或负责人', - tableColumns: { - name: 'MCP 服务', - category: '业务域', - owner: '维护人', - scope: '适用场景', - runtime: '调用地址', - version: '当前版本', - metric: '超时配置' - } - }, - tasks: { - assetType: 'task', - label: '任务', - typeLabel: '任务', - createButtonLabel: '任务已接入', - hintText: '任务页签已接到真实资产 API,可查看调度周期、执行 Agent 和最近执行结果。', - searchPlaceholder: '搜索任务名称、编码或负责人', - tableColumns: { - name: '任务名称', - category: '业务域', - owner: '负责人', - scope: '适用场景', - runtime: '调度周期', - version: '当前版本', - metric: '执行 Agent' - } - } -} - -const TAB_META = { - financialRules: { - assetType: 'rule', - typeKey: 'rules', - label: '财务规则', - typeLabel: '财务规则', - createButtonLabel: '财务规则已接入', - hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。', - searchPlaceholder: '搜索财务规则名称、编码或负责人', - tableColumns: RULE_TABLE_COLUMNS, - showRuntimeColumn: false, - showStatusColumn: false, - badgeTone: 'emerald' - }, - riskRules: { - assetType: 'rule', - typeKey: 'rules', - label: '风险规则', - typeLabel: '风险规则', - createButtonLabel: '风险规则已接入', - hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。', - searchPlaceholder: '搜索风险规则名称、编码或负责人', - tableColumns: RULE_TABLE_COLUMNS, - showRuntimeColumn: false, - showVersionColumn: false, - showStatusColumn: false, - badgeTone: 'rose' - }, - skills: { - ...TYPE_META.skills, - typeKey: 'skills', - badgeTone: 'blue' - }, - mcp: { - ...TYPE_META.mcp, - typeKey: 'mcp', - badgeTone: 'amber' - }, - tasks: { - ...TYPE_META.tasks, - typeKey: 'tasks', - badgeTone: 'violet' - } -} - -const STATUS_META = { - draft: { label: '草稿中', tone: 'draft' }, - review: { label: '待审核', tone: 'warning' }, - active: { label: '已上线', tone: 'success' }, - disabled: { label: '已停用', tone: 'disabled' } -} - -const REVIEW_META = { - approved: { label: '已通过', tone: 'success' }, - pending: { label: '待审核', tone: 'warning' }, - rejected: { label: '已驳回', tone: 'danger' } -} - -const VERSION_STATE_META = { - published: { label: '已上线', tone: 'success' }, - draft: { label: '草稿', tone: 'draft' }, - pending_review: { label: '待审核', tone: 'warning' }, - approved: { label: '已通过待上线', tone: 'success' }, - rejected: { label: '已驳回', tone: 'danger' }, - history: { label: '历史版本', tone: 'disabled' } -} - -const DOMAIN_LABELS = { - expense: '报销', - ar: '应收', - ap: '应付', - knowledge: '知识', - system: '系统' -} - -const SCENARIO_LABELS = { - expense: '报销', - risk_check: '风险检查', - duplicate_expense: '重复报销', - explain: '规则解释', - invoice_anomaly: '票据异常', - travel_policy: '差旅制度', - travel_standard: '差旅标准', - communication_expense: '通信费报销', - expense_standard: '费用标准', - accounts_payable: '应付', - accounts_receivable: '应收', - approval_required: '需审批', - query: '查询', - summary: '汇总', - system: '系统', - schedule: '调度', - rule_center: '规则中心', - review_digest: '待审摘要', - aging_summary: '账龄汇总', - invoice_validation: '发票验真' -} - -const DETAIL_TITLES = { - rules: { - configTitle: '规则元信息', - configDesc: '展示规则编码、版本、业务域和当前审核 / 上线状态。', - detailTitle: '规则版本说明', - detailDesc: '规则正文由 Markdown 驱动,保存后会生成新的版本快照。', - outputTitle: '审核与上线', - outputDesc: '规则上线受审核状态控制,未审核通过的版本会被后端拦截。', - ruleListTitle: '上线要求', - checkListTitle: '当前状态', - triggerTitle: '适用场景', - triggerDesc: '当前规则注册到的业务场景', - toolTitle: '关联信息', - toolDesc: '规则当前审核、保存和版本快照信息', - historyTitle: '版本历史', - historyDesc: '最近 5 个规则版本', - publishTitle: '上线控制', - publishDesc: '正式上线会调用后端激活接口,审核未通过时会被拦截。' - }, - skills: { - configTitle: '技能配置', - configDesc: '展示技能编码、输入摘要、版本和业务域。', - detailTitle: '技能结构', - detailDesc: '按输入、输出和依赖组织技能定义。', - outputTitle: '输出契约', - outputDesc: '技能详情重点展示输入参数、输出参数和依赖能力。', - ruleListTitle: '输出要求', - checkListTitle: '当前快照', - triggerTitle: '适用场景', - triggerDesc: '当前技能注册到的场景标签', - toolTitle: '依赖能力', - toolDesc: '技能当前依赖的数据库或其他能力', - historyTitle: '版本历史', - historyDesc: '最近版本记录', - publishTitle: '发布状态', - publishDesc: '技能当前状态由资产中心统一管理。' - }, - mcp: { - configTitle: 'MCP 连接配置', - configDesc: '展示服务地址、超时和调用方式。', - detailTitle: '服务协议', - detailDesc: '按服务类型、鉴权方式和降级策略组织外部服务信息。', - outputTitle: '调用约束', - outputDesc: 'MCP 详情重点展示鉴权方式、返回策略和最近调用状态。', - ruleListTitle: '调用约束', - checkListTitle: '最近状态', - triggerTitle: '适用场景', - triggerDesc: '当前 MCP 覆盖的业务场景', - toolTitle: '运行信息', - toolDesc: '结合 AgentRun 中的 ToolCall 还原最近一次调用状态', - historyTitle: '版本历史', - historyDesc: '最近版本记录', - publishTitle: '服务状态', - publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。' - }, - tasks: { - configTitle: '任务配置', - configDesc: '展示调度周期、执行 Agent 和任务编码。', - detailTitle: '任务结构', - detailDesc: '按调度计划、目标场景和运行结果组织任务信息。', - outputTitle: '运行要求', - outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。', - ruleListTitle: '运行要求', - checkListTitle: '最近执行', - triggerTitle: '适用场景', - triggerDesc: '当前任务覆盖的业务场景', - toolTitle: '最近调用', - toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况', - historyTitle: '版本历史', - historyDesc: '最近版本记录', - publishTitle: '调度状态', - publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。' - } -} - -const STATUS_OPTIONS = [ - { value: '', label: '全部状态' }, - { value: 'draft', label: '草稿中' }, - { value: 'review', label: '待审核' }, - { value: 'active', label: '已上线' }, - { value: 'disabled', label: '已停用' } -] - -const EXPENSE_RULE_BLOCK_PATTERN = /```expense-rule\s*([\s\S]*?)\s*```/i -const RULE_SPREADSHEET_BLOCK_PATTERN = /```rule-spreadsheet\s*([\s\S]*?)\s*```/i - -const RULE_TEMPLATE_LABELS = { - travel_standard_v1: '差旅标准模板', - expense_amount_limit_v1: '金额上限模板', - attachment_requirement_v1: '附件要求模板', - general_policy_v1: '通用制度模板' -} - -const RULE_TAB_TAG_ALIASES = { - financialRules: new Set(['财务规则', '财务', 'financialrule', 'financialrules', 'financerule', 'financerules', 'financial', 'finance']), - riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk']) -} - -const RISK_SCENARIO_OPTIONS = [ - { value: '', label: '全部场景' }, - { value: '差旅', label: '差旅' }, - { value: '发票', label: '发票' }, - { value: '餐饮招待', label: '餐饮招待' }, - { value: '交通出行', label: '交通出行' }, - { value: '办公物料', label: '办公物料' }, - { value: '费用科目', label: '费用科目' }, - { value: '通用', label: '通用' } -] - -const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean)) - -const LEGACY_RISK_SCENARIO_KEYS = new Set([ - 'expense', - 'risk_check', - 'travel', - 'meal', - 'invoice', - 'travel_policy', - 'travel_standard', - 'attachment_policy', - 'scene_policy', - 'invoice_anomaly', - 'communication_expense', - 'expense_standard', - 'approval_required' -]) - -const SPREADSHEET_DETAIL_MODE = 'spreadsheet' -const JSON_RISK_DETAIL_MODE = 'json_risk' -const PREVIEW_RULE_ID = 'preview-rule-expense-company-travel-expense' -const PREVIEW_RULE_CODE = 'rule.expense.company_travel_expense_reimbursement' -const PREVIEW_RULE_VERSION_SPECS = [ - { - version: 'v1.2.0', - fileName: '公司差旅费报销规则.xlsx', - updatedAt: '2026-05-17T09:30:00Z', - updatedBy: '王楠', - note: '补充城市分级与住宿限额示例。', - source: 'preview', - isCurrent: true - }, - { - version: 'v1.1.0', - fileName: '公司差旅费报销规则-v1.1.0.xlsx', - updatedAt: '2026-05-14T15:20:00Z', - updatedBy: '顾承宇', - note: '新增票据要求与超标审批列。', - source: 'preview', - isCurrent: false - }, - { - version: 'v1.0.0', - fileName: '公司差旅费报销规则-v1.0.0.xlsx', - updatedAt: '2026-05-10T11:10:00Z', - updatedBy: '系统初始化', - note: '首版差旅费报销规则表预览。', - source: 'preview', - isCurrent: false - } -] - -function buildPreviewSpreadsheetMeta(spec) { - return { - file_name: spec.fileName, - storage_key: `preview/agent-assets/${PREVIEW_RULE_ID}/${spec.version}/${encodeURIComponent(spec.fileName)}`, - mime_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - size_bytes: 82416, - checksum: `preview-${spec.version.replace(/\./g, '-')}`, - updated_at: spec.updatedAt, - updated_by: spec.updatedBy, - source: spec.source - } -} - -function buildPreviewSpreadsheetVersionMarkdown(spec) { - const metadata = buildPreviewSpreadsheetMeta(spec) - return [ - '# 公司差旅费报销规则', - '', - '## 规则载体', - '', - '- 页面状态:前端预览', - `- 当前规则版本:\`${spec.version}\``, - `- 表格文件:\`${spec.fileName}\``, - `- 最近更新人:${spec.updatedBy}`, - `- 最近更新时间:${spec.updatedAt}`, - '', - '## 说明', - '', - '- 当前环境暂无真实规则文件,先用于展示 Excel 规则详情页布局。', - '- 真实上传、编辑与版本回写逻辑会在接好正式数据后启用。', - '', - '```rule-spreadsheet', - JSON.stringify(metadata, null, 2), - '```' - ].join('\n') -} - -function createPreviewRuleDetailPayload() { - const recentVersions = PREVIEW_RULE_VERSION_SPECS.map((spec, index) => ({ - id: `${PREVIEW_RULE_ID}-version-${index + 1}`, - asset_id: PREVIEW_RULE_ID, - version: spec.version, - content: buildPreviewSpreadsheetVersionMarkdown(spec), - content_type: 'markdown', - change_note: spec.note, - created_by: spec.updatedBy, - created_at: spec.updatedAt, - is_current: spec.isCurrent - })) - const currentSpec = PREVIEW_RULE_VERSION_SPECS[0] - const currentMeta = buildPreviewSpreadsheetMeta(currentSpec) - - return { - id: PREVIEW_RULE_ID, - asset_type: 'rule', - code: PREVIEW_RULE_CODE, - name: '公司差旅费报销规则', - description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。', - domain: 'expense', - scenario_json: ['差旅'], - owner: '财务制度管理组', - reviewer: '顾承宇', - status: 'active', - current_version: currentSpec.version, - published_version: currentSpec.version, - working_version: currentSpec.version, - config_json: { - severity: 'medium', - enabled: true, - tag: '财务规则', - detail_mode: 'spreadsheet', - runtime_kind: 'travel_policy', - scenario_category: '差旅', - ai_review_category: '差旅', - rule_template_label: '差旅报销 Excel 模板', - rule_document: { - ...currentMeta, - asset_version: currentSpec.version - } - }, - created_at: '2026-05-10T11:10:00Z', - updated_at: currentSpec.updatedAt, - current_version_content: recentVersions[0].content, - current_version_content_type: 'markdown', - current_version_change_note: currentSpec.note, - recent_versions: recentVersions, - latest_review: { - id: `${PREVIEW_RULE_ID}-review-1`, - asset_id: PREVIEW_RULE_ID, - version: currentSpec.version, - reviewer: '顾承宇', - review_status: 'approved', - review_note: '当前为页面预览态,先确认布局与交互位置。', - reviewed_at: '2026-05-17T10:00:00Z', - created_at: '2026-05-17T10:00:00Z' - } - } -} - -function buildPreviewRuleListItem() { - const payload = createPreviewRuleDetailPayload() - return { - ...buildListItem(payload), - isPreviewMock: true - } -} - -function buildPreviewRuleDetail() { - const detail = buildDetailViewModel(createPreviewRuleDetailPayload(), []) - return { - ...detail, - isPreviewMock: true - } -} - -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 readRuleDocumentMeta(value) { - const configJson = readConfigJson(value) - return isPlainObject(configJson.rule_document) ? configJson.rule_document : null -} - -function isSpreadsheetRuleSource(value) { - const configJson = readConfigJson(value) - return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE -} - -function isJsonRiskRuleSource(value) { - const configJson = readConfigJson(value) - return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === JSON_RISK_DETAIL_MODE -} - -function normalizeRuleTagValue(value) { - return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '') -} - -function collectRuleTagValues(source) { - const configJson = readConfigJson(source) - const rawValues = [ - configJson.tag, - configJson.rule_tag, - ...(Array.isArray(configJson.tags) ? configJson.tags : []), - ...(Array.isArray(configJson.rule_tags) ? configJson.rule_tags : []) - ] - - return rawValues.map((item) => normalizeText(item)).filter(Boolean) -} - -function resolveRuleTabId(source) { - const code = normalizeText(source?.code || '').toLowerCase() - if (code.startsWith('risk.')) { - return 'riskRules' - } - if (isJsonRiskRuleSource(source)) { - return 'riskRules' - } - - const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item)) - - if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) { - return 'riskRules' - } - if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.financialRules.has(item))) { - return 'financialRules' - } - return '' -} - -function resolveTabId(source, typeKey) { - if (typeKey === 'rules') { - return resolveRuleTabId(source) - } - return typeKey -} - -function resolveTabMeta(tabId, typeKey) { - if (TAB_META[tabId]) { - return TAB_META[tabId] - } - if (typeKey === 'rules') { - return { - ...TYPE_META.rules, - typeKey: 'rules', - badgeTone: 'emerald' - } - } - return TAB_META[typeKey] -} - -function resolveRiskRuleDescription(payload) { - if (!isPlainObject(payload)) { - return '' - } - return normalizeText(payload.description) -} - -function resolveRiskRuleSourceRef(payload) { - if (!isPlainObject(payload)) { - return '' - } - const metadata = isPlainObject(payload.metadata) ? payload.metadata : {} - return normalizeText(metadata.source_ref) -} - -function inferRiskCategoryFromCode(code) { - const normalized = normalizeText(code).toLowerCase() - if (normalized.startsWith('risk.travel.')) { - return '差旅' - } - if (normalized.startsWith('risk.invoice.')) { - return '发票' - } - if (normalized.includes('entertainment') || normalized.includes('meal_localized')) { - return '餐饮招待' - } - if (normalized.includes('consecutive_transport')) { - return '交通出行' - } - if (normalized.startsWith('risk.expense.')) { - return '费用科目' - } - return '通用' -} - -function normalizeRiskScenarioCategory(value) { - const normalized = normalizeText(value) - return RISK_SCENARIO_VALUES.has(normalized) ? normalized : '' -} - -function readScenarioItems(source) { - if (Array.isArray(source?.scenario_json)) { - return source.scenario_json - } - if (Array.isArray(source?.scenarioList)) { - return source.scenarioList - } - return [] -} - -function resolveRiskRuleCategory(source) { - const configJson = readConfigJson(source) - const explicit = normalizeRiskScenarioCategory(configJson.risk_category) - if (explicit) { - return explicit - } - - const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category) - if (payloadCategory) { - return payloadCategory - } - - const scenarioItems = readScenarioItems(source) - const businessScenario = scenarioItems - .map((item) => normalizeText(item)) - .find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item)) - if (businessScenario) { - return businessScenario - } - - return inferRiskCategoryFromCode(source?.code) -} - -function inferFinancialRuleCategory(source) { - const configJson = readConfigJson(source) - const explicit = - normalizeRiskScenarioCategory(configJson.scenario_category) || - normalizeRiskScenarioCategory(configJson.ai_review_category) || - normalizeRiskScenarioCategory(configJson.risk_category) || - normalizeRiskScenarioCategory(source?.scenario_category) || - normalizeRiskScenarioCategory(source?.risk_category) - if (explicit) { - return explicit - } - - const scenarioCategory = readScenarioItems(source) - .map((item) => normalizeRiskScenarioCategory(item)) - .find(Boolean) - if (scenarioCategory) { - return scenarioCategory - } - - const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {} - const haystack = [ - source?.code, - source?.name, - source?.description, - configJson.runtime_kind, - configRuntimeRule.kind, - configRuntimeRule.scenario, - configRuntimeRule.template_key, - ...readScenarioItems(source) - ] - .map((item) => normalizeText(item).toLowerCase()) - .filter(Boolean) - .join(' ') - - if (!haystack) { - return '通用' - } - if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) { - return '差旅' - } - if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) { - return '发票' - } - if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) { - return '餐饮招待' - } - if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) { - return '交通出行' - } - if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) { - return '办公物料' - } - if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) { - return '费用科目' - } - return '通用' -} - -function resolveRuleScenarioCategory(source, tabId = '') { - const resolvedTabId = tabId || resolveRuleTabId(source) - if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) { - return resolveRiskRuleCategory(source) - } - if (resolvedTabId === 'financialRules') { - return inferFinancialRuleCategory(source) - } - return '' -} - -function buildRiskListSubtitle(text, maxLength = 42) { - const normalized = normalizeText(text) - if (!normalized) { - return '平台内置风险规则' - } - const firstSentence = normalized.split(/[。;;!?\n]/)[0] || normalized - if (firstSentence.length <= maxLength) { - return firstSentence - } - return `${firstSentence.slice(0, maxLength)}…` -} - -function applyRiskRuleJsonState(target, payload, apiPayload) { - const rulePayload = isPlainObject(payload) ? payload : {} - const fullDescription = - resolveRiskRuleDescription(rulePayload) || - normalizeText(apiPayload?.description) || - normalizeText(target.riskRuleDescription) - const riskCategory = - normalizeText(rulePayload.risk_category) || - resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload }) - - return { - ...target, - riskRuleDescription: fullDescription, - riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48), - riskCategory, - scope: riskCategory, - riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload), - riskRuleSummary: { - name: apiPayload?.name || target.name, - evaluator: apiPayload?.evaluator || rulePayload.evaluator || '', - ontologySignal: apiPayload?.ontology_signal || rulePayload.ontology_signal || '', - inputs: apiPayload?.inputs || rulePayload.inputs || {}, - outcomes: apiPayload?.outcomes || rulePayload.outcomes || {} - }, - riskRuleJsonText: JSON.stringify(rulePayload, null, 2) - } -} - -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 extractSpreadsheetMetaFromMarkdown(markdown) { - const match = String(markdown || '').match(RULE_SPREADSHEET_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) { - return 'AG' - } - return text.slice(0, 2).toUpperCase() -} - -function formatDateTime(value) { - if (!value) { - return '未记录' - } - - const date = new Date(value) - if (Number.isNaN(date.getTime())) { - return String(value) - } - - return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }) - .format(date) - .replace(/\//g, '-') -} - -function resolveDomainLabel(value) { - return DOMAIN_LABELS[value] || normalizeText(value) || '未分类' -} - -function resolveStatusMeta(value) { - return STATUS_META[value] || { label: normalizeText(value) || '未知状态', tone: 'draft' } -} - -function resolveReviewMeta(value) { - return REVIEW_META[value] || { label: '暂无审核', tone: 'draft' } -} - -function resolveTimelineEventMeta(value) { - return { - created: { label: '创建工作稿', icon: 'mdi mdi-file-document-edit-outline', tone: 'draft' }, - submitted: { label: '提交审核', icon: 'mdi mdi-send-outline', tone: 'warning' }, - approved: { label: '审核通过', icon: 'mdi mdi-check-decagram-outline', tone: 'success' }, - rejected: { label: '审核驳回', icon: 'mdi mdi-close-octagon-outline', tone: 'danger' }, - published: { label: '正式上线', icon: 'mdi mdi-rocket-launch-outline', tone: 'success' }, - restored: { label: '恢复生成工作稿', icon: 'mdi mdi-history', tone: 'info' } - }[normalizeText(value)] || { label: normalizeText(value) || '版本事件', icon: 'mdi mdi-circle-medium', tone: 'draft' } -} - -function resolveDiffChangeMeta(value) { - return { - added: { label: '新增', tone: 'success' }, - removed: { label: '删除', tone: 'danger' }, - modified: { label: '修改', tone: 'warning' } - }[normalizeText(value)] || { label: normalizeText(value) || '变化', tone: 'draft' } -} - -function formatScenarioList(items) { - if (!Array.isArray(items) || !items.length) { - return '未配置场景' - } - - return items - .map((item) => SCENARIO_LABELS[item] || item) - .filter(Boolean) - .join(' / ') -} - -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 - ), - spreadsheetMeta: extractSpreadsheetMetaFromMarkdown(rawContent), - contentType: item.content_type, - createdBy: item.created_by, - isCurrent: Boolean(item.is_current), - isPublished: Boolean(item.is_published), - isWorking: Boolean(item.is_working), - lifecycleState: item.lifecycle_state || 'history', - lifecycleMeta: VERSION_STATE_META[item.lifecycle_state] || VERSION_STATE_META.history - } - }) -} - -function resolveTypeKey(assetType) { - if (assetType === 'rule') { - return 'rules' - } - if (assetType === 'skill') { - return 'skills' - } - if (assetType === 'mcp') { - return 'mcp' - } - return 'tasks' -} - -function formatSeverity(value) { - const severity = normalizeText(value).toLowerCase() - if (severity === 'high') { - return '高风险' - } - if (severity === 'medium') { - return '中风险' - } - if (severity === 'low') { - return '低风险' - } - return '未配置' -} - -function formatInputSummary(items) { - if (!Array.isArray(items) || !items.length) { - return '无输入' - } - return `${items.length} 项输入` -} - -function formatOutputSummary(items) { - if (!Array.isArray(items) || !items.length) { - return '无输出' - } - return `${items.length} 项输出` -} - -function formatTaskRisk(scenarios) { - if (Array.isArray(scenarios) && scenarios.includes('risk_check')) { - return '高风险' - } - if ( - Array.isArray(scenarios) && - (scenarios.includes('accounts_receivable') || scenarios.includes('accounts_payable')) - ) { - return '中风险' - } - return '常规' -} - -function findLatestTaskRun(runs, assetId) { - return runs.find((item) => item.task_id === assetId) || null -} - -function findLatestMcpCall(runs, assetCode) { - const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '') - - for (const run of runs) { - for (const toolCall of run.tool_calls || []) { - const toolName = normalizeText(toolCall.tool_name) - if ( - toolName === expectedToolName || - toolName.endsWith(expectedToolName) || - expectedToolName.endsWith(toolName) - ) { - return { - run, - toolCall - } - } - } - } - - return null -} - -function buildRowRuntime(asset, typeKey) { - if (typeKey === 'rules') { - return formatSeverity(asset.config_json?.severity) - } - if (typeKey === 'skills') { - return formatInputSummary(asset.config_json?.input_schema) - } - if (typeKey === 'mcp') { - return normalizeText(asset.config_json?.endpoint) || '未配置地址' - } - return normalizeText(asset.config_json?.cron) || '未配置调度' -} - -function buildRowMetric(asset, typeKey) { - if (typeKey === 'rules') { - return normalizeText(asset.modified_by) || '未记录' - } - if (typeKey === 'skills') { - return '进入详情查看输出' - } - if (typeKey === 'mcp') { - return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时' - } - return normalizeText(asset.config_json?.agent) || '未配置 Agent' -} - -function formatSpreadsheetChangeSummary(summary) { - const normalized = normalizeText(summary) - return ( - normalized - .replace(/^(ONLYOFFICE\s*)?在线编辑[::]\s*/i, '') - .replace(/^ONLYOFFICE\s*在线编辑保存[。.]?\s*/i, '') - .replace(/^保存表格[::]\s*/i, '') - .trim() || '表格内容已保存。' - ) -} - -function buildListItem(asset) { - const typeKey = resolveTypeKey(asset.asset_type) - const tabId = resolveTabId(asset, typeKey) - if (!tabId) { - return null - } - - const tabMeta = resolveTabMeta(tabId, typeKey) - const statusMeta = resolveStatusMeta(asset.status) - const workingVersion = asset.working_version || asset.current_version || '-' - const changeCount = - typeof asset.change_count === 'number' - ? asset.change_count - : Array.isArray(asset.recent_versions) - ? Math.max(asset.recent_versions.length - 1, 0) - : 0 - const modifiedBy = - normalizeText(asset.modified_by) || - normalizeText( - Array.isArray(asset.recent_versions) - ? asset.recent_versions.find((item) => item.version === workingVersion)?.created_by - : '' - ) - const isRiskRule = tabId === 'riskRules' - const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset) - const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset) - const ruleDocument = readRuleDocumentMeta(asset) - const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : '' - const listSubtitle = isRiskRule - ? buildRiskListSubtitle(asset.description) - : normalizeText(asset.description) - - return { - id: asset.id, - tabId, - type: typeKey, - isPreviewMock: Boolean(asset.isPreviewMock), - usesSpreadsheetRule, - usesJsonRiskRule, - ruleDocument, - typeLabel: tabMeta.typeLabel, - short: makeShort(asset.name), - name: asset.name, - code: asset.code, - summary: listSubtitle, - listSubtitle, - category: resolveDomainLabel(asset.domain), - owner: asset.owner, - reviewer: asset.reviewer || '待分配', - scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json), - riskCategory: ruleScenarioCategory, - model: buildRowRuntime(asset, typeKey), - version: workingVersion, - versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion, - publishedVersion: asset.published_version || '-', - workingVersion, - status: statusMeta.label, - statusValue: asset.status, - statusTone: statusMeta.tone, - hitRate: buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey), - modifiedBy, - changeCount, - updatedAt: formatDateTime(asset.updated_at), - badgeTone: tabMeta.badgeTone, - domainValue: asset.domain - } -} - -function buildRuleFields(detail) { - const ruleDocument = readRuleDocumentMeta(detail) - const ruleScenarioCategory = resolveRuleScenarioCategory(detail) - return [ - { label: '规则编码', value: detail.code }, - { - label: '明细载体', - value: isSpreadsheetRuleSource(detail) ? 'Excel 表格' : 'Markdown / JSON' - }, - ...(ruleDocument - ? [ - { - label: '关联文件', - value: normalizeText(ruleDocument.file_name) || '未上传' - } - ] - : []), - { - 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: ruleScenarioCategory || '通用' }, - { label: '线上版本', value: detail.published_version || '-' }, - { label: '工作版本', value: detail.working_version || detail.current_version || '-' } - ] -} - -function buildSkillFields(detail) { - const content = detail.current_version_content || {} - return [ - { label: '技能编码', value: detail.code }, - { label: '业务域', value: resolveDomainLabel(detail.domain) }, - { - label: '输入参数', - value: Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('、') : '未配置' - }, - { - label: '输出参数', - value: Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('、') : '未配置' - } - ] -} - -function buildMcpFields(detail, latestCall) { - const content = detail.current_version_content || {} - return [ - { label: '服务编码', value: detail.code }, - { label: '调用地址', value: normalizeText(detail.config_json?.endpoint) || '未配置' }, - { label: '鉴权方式', value: normalizeText(content.auth_mode) || '未配置' }, - { - label: '最近调用', - value: latestCall ? `${latestCall.toolCall.status} / ${formatDateTime(latestCall.run.started_at)}` : '暂无调用记录' - } - ] -} - -function buildTaskFields(detail, latestRun) { - const content = detail.current_version_content || {} - return [ - { label: '任务编码', value: detail.code }, - { label: 'Cron', value: normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置' }, - { label: '执行 Agent', value: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置' }, - { label: '风险等级', value: formatTaskRisk(detail.scenario_json) }, - { - label: '最近执行', - value: latestRun ? formatDateTime(latestRun.started_at) : '暂无执行记录' - } - ] -} - -function buildFields(detail, typeKey, latestRun, latestCall) { - if (typeKey === 'rules') { - return buildRuleFields(detail) - } - if (typeKey === 'skills') { - return buildSkillFields(detail) - } - if (typeKey === 'mcp') { - return buildMcpFields(detail, latestCall) - } - return buildTaskFields(detail, latestRun) -} - -function buildPromptSections(detail, typeKey, latestRun, latestCall) { - const content = detail.current_version_content || {} - - if (typeKey === 'skills') { - return [ - { - title: '输入参数', - intent: '技能入口', - content: Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('\n') : '未配置输入参数。' - }, - { - title: '输出参数', - intent: '技能产出', - content: Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('\n') : '未配置输出参数。' - }, - { - title: '依赖能力', - intent: '外部依赖', - content: - Array.isArray(content.dependencies) && content.dependencies.length - ? content.dependencies.join('\n') - : '当前技能未声明外部依赖。' - } - ] - } - - if (typeKey === 'mcp') { - return [ - { - title: '服务类型', - intent: '协议说明', - content: normalizeText(content.service_type) || '未配置服务类型。' - }, - { - title: '鉴权方式', - intent: '安全要求', - content: normalizeText(content.auth_mode) || '未配置鉴权方式。' - }, - { - title: '降级策略', - intent: '失败处理', - content: normalizeText(content.degrade_strategy) || '未配置降级策略。' - } - ] - } - - return [ - { - title: '任务场景', - intent: '调度目标', - content: formatScenarioList(detail.scenario_json) - }, - { - title: '执行 Agent', - intent: '运行主体', - content: normalizeText(content.target_agent) || normalizeText(detail.config_json?.agent) || '未配置执行 Agent。' - }, - { - title: '最近执行结果', - intent: '运行反馈', - content: latestRun?.result_summary || latestRun?.error_message || '暂无执行记录。' - } - ] -} - -function buildOutputRules(detail, typeKey, latestRun, latestCall) { - const content = detail.current_version_content || {} - - if (typeKey === 'rules') { - if (isSpreadsheetRuleSource(detail)) { - return [ - '规则详情页以内联 Excel 表格作为主载体,管理员可直接编辑当前版本。', - '上传新的 Excel 文件后,会自动生成新的规则版本快照。', - '切换到历史版本时仅支持预览,不允许直接覆盖历史版本。', - '规则表发生变更后,仍需完成审核才能再次正式上线。' - ] - } - return [ - '规则使用固定模板落 Markdown,并配套维护 runtime_rule JSON。', - '保存 Markdown 或 JSON 都会生成新版本快照。', - '未审核通过的规则版本不能正式上线。', - '版本切换当前只影响前端展示内容,不会直接回滚后端版本。' - ] - } - - if (typeKey === 'skills') { - return [ - `输入参数:${Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('、') : '未配置'}`, - `输出参数:${Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('、') : '未配置'}`, - `依赖能力:${Array.isArray(content.dependencies) && content.dependencies.length ? content.dependencies.join('、') : '未声明'}` - ] - } - - if (typeKey === 'mcp') { - return [ - `服务地址:${normalizeText(detail.config_json?.endpoint) || '未配置'}`, - `鉴权方式:${normalizeText(content.auth_mode) || '未配置'}`, - `降级策略:${normalizeText(content.degrade_strategy) || '未配置'}` - ] - } - - return [ - `调度周期:${normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置'}`, - `执行 Agent:${normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置'}`, - `风险等级:${formatTaskRisk(detail.scenario_json)}`, - `最近执行结果:${latestRun?.status || '暂无执行记录'}` - ] -} - -function buildTests(detail, typeKey, latestRun, latestCall) { - if (typeKey === 'rules') { - const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status) - return [ - { - name: '审核状态', - input: detail.latest_review?.version || detail.current_version || '暂无版本', - result: reviewMeta.label, - tone: reviewMeta.tone - }, - { - name: '上线状态', - input: detail.current_version || '暂无版本', - result: resolveStatusMeta(detail.status).label, - tone: resolveStatusMeta(detail.status).tone - } - ] - } - - if (typeKey === 'skills') { - const content = detail.current_version_content || {} - return [ - { - name: '输入数量', - input: detail.current_version || '暂无版本', - result: `${content.inputs?.length || 0} 项`, - tone: 'success' - }, - { - name: '输出数量', - input: detail.current_version || '暂无版本', - result: `${content.outputs?.length || 0} 项`, - tone: 'success' - } - ] - } - - if (typeKey === 'mcp') { - return [ - { - name: '最近调用状态', - input: latestCall?.run?.run_id || '暂无调用', - result: latestCall?.toolCall?.status || '未记录', - tone: latestCall?.toolCall?.status === 'failed' ? 'danger' : 'success' - }, - { - name: '最近调用耗时', - input: latestCall?.toolCall?.tool_name || '暂无调用', - result: - typeof latestCall?.toolCall?.duration_ms === 'number' - ? `${latestCall.toolCall.duration_ms} ms` - : '未记录', - tone: 'success' - } - ] - } - - return [ - { - name: '最近运行状态', - input: latestRun?.run_id || '暂无运行', - result: latestRun?.status || '未记录', - tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'success' - }, - { - name: '结果摘要', - input: latestRun?.agent || normalizeText(detail.config_json?.agent) || '未配置', - result: latestRun?.result_summary || '暂无摘要', - tone: 'success' - } - ] -} - -function buildTools(detail, typeKey, latestRun, latestCall) { - const content = detail.current_version_content || {} - - if (typeKey === 'skills') { - return (content.dependencies || []).map((item) => ({ - name: item, - scope: '技能依赖', - mode: '读取', - tone: 'safe' - })) - } - - if (typeKey === 'mcp') { - return [ - { - name: normalizeText(content.service_type) || '未配置服务类型', - scope: '服务类型', - mode: 'MCP', - tone: 'active' - }, - { - name: normalizeText(content.auth_mode) || '未配置鉴权方式', - scope: '鉴权', - mode: '安全', - tone: 'safe' - }, - { - name: latestCall?.run?.run_id || '暂无调用记录', - scope: '最近 Run', - mode: latestCall?.toolCall?.status || '未执行', - tone: latestCall?.toolCall?.status === 'failed' ? 'danger' : 'active' - } - ] - } - - return [ - { - name: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置 Agent', - scope: '执行 Agent', - mode: '调度', - tone: 'active' - }, - { - name: latestRun?.run_id || '暂无执行记录', - scope: '最近 Run', - mode: latestRun?.status || '未执行', - tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'active' - }, - { - name: latestRun?.permission_level || '未记录', - scope: '权限级别', - mode: 'Trace', - tone: 'safe' - } - ] -} - -function buildPublishDescription(detail, typeKey) { - if (typeKey === 'rules') { - if (detail.published_version && detail.working_version && detail.published_version !== detail.working_version) { - return '当前存在尚未上线的工作版本,系统运行仍以线上版本为准。' - } - if (detail.status === 'active') { - return '当前规则线上版本已生效,仍可继续保存新的工作版本并重新走审核。' - } - return '当前规则需要先完成审核,再调用上线接口正式激活。' - } - - return DETAIL_TITLES[typeKey].publishDesc -} - -function buildDetailViewModel(detail, runs) { - const typeKey = resolveTypeKey(detail.asset_type) - const tabId = resolveTabId(detail, typeKey) || typeKey - const tabMeta = resolveTabMeta(tabId, typeKey) - 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 || [], detail) - const previewVersion = history.find((item) => item.isWorking) || history[0] || null - const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(detail) - const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(detail) - const ruleDocument = readRuleDocumentMeta(detail) - 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' - const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : '' - - return { - id: detail.id, - tabId, - type: typeKey, - typeLabel: tabMeta.typeLabel, - short: makeShort(detail.name), - name: detail.name, - code: detail.code, - summary: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description, - listSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : normalizeText(detail.description), - owner: detail.owner, - reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', - category: resolveDomainLabel(detail.domain), - scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json), - version: detail.working_version || detail.current_version || '-', - currentVersion: detail.current_version || '-', - publishedVersion: detail.published_version || '-', - workingVersion: detail.working_version || detail.current_version || '-', - displayVersion: previewVersion?.version || detail.working_version || detail.current_version || '-', - status: statusMeta.label, - statusValue: detail.status, - statusTone: statusMeta.tone, - hitRate: buildRowMetric(detail, typeKey), - updatedAt: formatDateTime(detail.updated_at), - badgeTone: tabMeta.badgeTone, - configJson, - usesSpreadsheetRule, - usesJsonRiskRule, - riskRuleJsonText: '{}', - riskRuleSummary: null, - riskRuleDescription: '', - riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '', - riskRuleSourceRef: '', - riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '', - ruleDocument, - scenarioList: typeKey === 'rules' && ruleScenarioCategory - ? [ruleScenarioCategory] - : 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 || '', - reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at), - reviewNote: detail.latest_review?.review_note || '', - latestRun, - latestCall, - fields: buildFields(detail, typeKey, latestRun, latestCall), - promptSections: - typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall), - outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall), - tests: buildTests(detail, typeKey, latestRun, latestCall), - triggers: - typeKey === 'rules' - ? [ruleScenarioCategory || '通用'] - : detail.scenario_json?.length - ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) - : ['未配置场景'], - tools: - typeKey === 'rules' - ? [ - { - name: detail.latest_review?.reviewer || '待分配审核人', - scope: '审核负责人', - mode: reviewMeta.label, - tone: reviewMeta.tone - }, - { - name: detail.published_version || '暂无版本', - scope: '线上版本', - mode: '正式生效', - tone: 'safe' - }, - { - name: detail.working_version || detail.current_version || '暂无版本', - scope: '工作版本', - mode: detail.current_version_change_note || '无版本说明', - tone: 'safe' - } - ] - : buildTools(detail, typeKey, latestRun, latestCall), - history, - configTitle: titles.configTitle, - configDesc: titles.configDesc, - detailTitle: titles.detailTitle, - detailDesc: titles.detailDesc, - outputTitle: titles.outputTitle, - outputDesc: titles.outputDesc, - ruleListTitle: titles.ruleListTitle, - checkListTitle: titles.checkListTitle, - triggerTitle: titles.triggerTitle, - triggerDesc: titles.triggerDesc, - toolTitle: titles.toolTitle, - toolDesc: titles.toolDesc, - historyTitle: titles.historyTitle, - historyDesc: titles.historyDesc, - publishTitle: titles.publishTitle, - publishDesc: buildPublishDescription(detail, typeKey), - publishMeta: - typeKey === 'rules' - ? `最近保存:${formatDateTime(detail.updated_at)}` - : latestRun - ? `最近运行:${formatDateTime(latestRun.started_at)}` - : `最近更新:${formatDateTime(detail.updated_at)}`, - publishState: statusMeta.label, - latestReviewVersion: detail.latest_review?.version || detail.current_version || '-', - loading: false - } -} - -function incrementVersion(version) { - const normalized = normalizeText(version).replace(/^v/i, '') - const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/) - - if (!match) { - return 'v1.0.0' - } - - const major = Number(match[1]) - const minor = Number(match[2]) - const patch = Number(match[3]) + 1 - return `v${major}.${minor}.${patch}` -} - -function buildReviewNote(status) { - if (status === 'approved') { - return '通过任务规则中心审核。' - } - if (status === 'rejected') { - return '在任务规则中心驳回当前版本。' - } - return '提交任务规则中心待审核。' -} +import { + buildReviewNote, + buildRuleConfigPayload, + buildSpreadsheetChangeRecordKey, + filterAuditAssets, + incrementVersion +} from './auditViewRuntimeModel.js' + +import { + TAB_META, + STATUS_OPTIONS, + RISK_SCENARIO_OPTIONS, + normalizeText, + readConfigJson, + resolveRuleTemplateLabel, + resolveTimelineEventMeta, + formatDateTime, + formatSpreadsheetChangeSummary, + resolveDiffChangeMeta, + resolveDomainLabel, + resolveStatusMeta, + resolveReviewMeta, + buildListItem, + buildDetailViewModel, + applyRiskRuleJsonState, + resolveRiskRuleDescription, + buildPreviewRuleDetail, + buildDefaultRuntimeRule, + stringifyRuntimeRule, + parseRuntimeRuleText, + buildMarkdownVersionContent +} from './auditViewModel.js' export default { name: 'AuditView', @@ -1978,31 +388,17 @@ export default { } return '' }) - const visibleSkills = computed(() => { - const normalizedKeyword = keyword.value.trim().toLowerCase() - - return currentAssets.value.filter((item) => { - const matchesKeyword = normalizedKeyword - ? [item.name, item.code, item.summary, item.owner, item.scope] - .filter(Boolean) - .some((value) => String(value).toLowerCase().includes(normalizedKeyword)) - : true - const matchesDomain = selectedDomain.value ? item.domainValue === selectedDomain.value : true - const matchesOwner = selectedOwner.value ? item.owner === selectedOwner.value : true - const matchesStatus = showStatusFilter.value - ? selectedStatus.value - ? item.statusValue === selectedStatus.value - : true - : true - const matchesRiskScenario = showRiskScenarioFilter.value - ? selectedRiskScenario.value - ? item.riskCategory === selectedRiskScenario.value - : true - : true - - return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario + const visibleSkills = computed(() => + filterAuditAssets(currentAssets.value, { + keyword: keyword.value, + selectedDomain: selectedDomain.value, + selectedOwner: selectedOwner.value, + selectedStatus: selectedStatus.value, + selectedRiskScenario: selectedRiskScenario.value, + showStatusFilter: showStatusFilter.value, + showRiskScenarioFilter: showRiskScenarioFilter.value }) - }) + ) watch( selectedSkill, @@ -2096,20 +492,6 @@ 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, @@ -2143,48 +525,7 @@ export default { } function getLatestSpreadsheetChangeKey(assetId) { - const records = spreadsheetChangeRecordsByAsset.value[assetId] || [] - const latest = records.find((item) => item?.changed_at) - if (!latest) { - return '' - } - const previewSignature = Array.isArray(latest.cell_changes) - ? latest.cell_changes - .slice(0, 8) - .map((item) => - [ - item?.sheet_name, - item?.cell, - item?.change_type, - item?.before_value, - item?.after_value - ] - .map((value) => normalizeText(value)) - .join(':') - ) - .join('|') - : '' - const sheetSignature = Array.isArray(latest.sheet_changes) - ? latest.sheet_changes - .map((item) => - [item?.sheet_name, item?.change_type] - .map((value) => normalizeText(value)) - .join(':') - ) - .join('|') - : '' - return [ - latest.id, - latest.changed_at, - latest.actor, - latest.summary, - latest.changed_sheet_count, - latest.changed_cell_count, - sheetSignature, - previewSignature - ] - .map((value) => normalizeText(value)) - .join('-') + return buildSpreadsheetChangeRecordKey(spreadsheetChangeRecordsByAsset.value[assetId] || []) } async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) { diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index c2a37b1..a276a22 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -4,23 +4,30 @@ import { useRouter } from 'vue-router' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' +import { useTravelReimbursementFlow } from './useTravelReimbursementFlow.js' +import { useTravelReimbursementComposerTools } from './useTravelReimbursementComposerTools.js' +import { useTravelReimbursementAttachments } from './useTravelReimbursementAttachments.js' +import { useTravelReimbursementSessionState } from './useTravelReimbursementSessionState.js' +import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementReviewDrawer.js' +import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js' +import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js' import { recognizeOcrFiles } from '../../services/ocr.js' import { fetchAgentRunDetail } from '../../services/agentAssets.js' -import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' +import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js' import { renderMarkdown } from '../../utils/markdown.js' -import { - clearAssistantSessionSnapshot, - readAssistantSessionSnapshot, - writeAssistantSessionSnapshot -} from '../../utils/assistantSessionSnapshot.js' +import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js' import { buildLocalExtractionProgressMessages, buildLocalIntentPreview, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, - summarizeSemanticIntentDetail, - TRANSPORT_KEYWORD_PATTERN + summarizeSemanticIntentDetail } from '../../utils/reimbursementTextInference.js' +import { + buildExpenseIntentConfirmationActions, + buildExpenseSceneSelectionActions +} from '../../utils/expenseAssistantActions.js' +import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js' import { calculateTravelReimbursement, fetchExpenseClaims, @@ -29,34 +36,126 @@ import { fetchExpenseClaimItemAttachmentMeta, uploadExpenseClaimItemAttachment } from '../../services/reimbursements.js' - -const aiAvatar = '/assets/header.png' -const userAvatar = '/assets/person.png' - -const SOURCE_LABELS = { - workbench: '来自个人工作台', - topbar: '来自发起报销', - detail: '来自智能录入', - upload: '来自附件上传', - requests: '来自报销列表' -} - -const SCENARIO_LABELS = { - expense: '报销', - accounts_receivable: '应收', - accounts_payable: '应付', - knowledge: '知识', - unknown: '通用' -} - -const INTENT_LABELS = { - query: '查询', - explain: '解释', - compare: '对比', - risk_check: '风险检查', - draft: '草稿生成', - operate: '动作请求' -} +import { + EXPENSE_TYPE_LABELS, + REVIEW_SLOT_CONFIG, + REVIEW_CATEGORY_PRESET_OPTIONS, + REVIEW_OTHER_CATEGORY_OPTIONS, + REVIEW_SCENE_OTHER_OPTION, + REVIEW_SCENE_OPTIONS, + DATE_INPUT_FORMAT, + cloneReviewDocumentDrafts, + buildReviewDocumentDrafts, + normalizeReviewDocumentComparableValue, + buildReviewDocumentCorrectionMessage, + buildReviewDocumentCorrectionContext, + cloneReviewEditFields, + buildReviewFormValues, + createEmptyInlineReviewState, + resolveReviewRecognizedSlotCards, + resolveReviewMissingSlotCards, + resolveReviewExtraMissingLabels, + formatConfidenceLabel, + resolveDocumentTypeLabel, + resolveExpenseTypeLabel, + buildReviewRecognizedLines, + buildReviewSlotMap, + resolveExpenseTypeCode, + isValidIsoDateString, + parseAmountNumber, + normalizeAmountValue, + extractAmountInputValue, + formatAmountDisplay, + inferPresetSceneFromReview, + formatReviewSceneDisplayValue, + summarizeReviewScene, + buildInlineReviewState, + buildReviewAttachmentStatus, + shouldShowReviewFactCard, + resolveReviewCategoryConfidenceScore, + buildReviewCategoryOptions, + buildReviewPanelConfidence, + buildLocallySyncedReviewPayload, + buildInlineReviewChangedLines, + buildLocalReviewSavedMessage, + buildReviewSubmitUserText, + mergeInlineReviewFields, + buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel, + buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel, + isTravelReviewPayload as isTravelReviewPayloadModel, + resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel, + buildClientTimeContext, + formatDraftApplyTime, + formatDateInputValue, + buildDraftSavedPayload, + buildReviewHeadline, + buildReviewSubline, + buildReviewStateLabel, + buildReviewStateTone, + buildReviewPlainFollowupCopy, + resolveReviewFooterActions, + resolveReviewSaveDraftAction, + buildReviewPrimaryButtonLabel, + buildReviewIntentText, + buildReviewSceneValue, + buildMissingRiskLine, + buildReviewRiskSummary as buildReviewRiskSummaryModel, + normalizeReviewRiskLevel, + buildLocalReviewCompletionMessage, + buildReviewRecognitionNotes, + buildReviewDocumentSummaries +} from './travelReimbursementReviewModel.js' +import { + buildDraftAssociationQueryPayload, + buildExpenseQueryHint, + buildExpenseQueryWindowLabel, + getExpenseQueryActivePage, + getExpenseQueryTotalPages, + getExpenseQueryVisibleRecords, + normalizeExpenseQueryPayload +} from './travelReimbursementExpenseQueryModel.js' +import { + MAX_ATTACHMENTS, + MAX_OCR_DOCUMENTS, + VISIBLE_ATTACHMENT_CHIPS, + buildAgentInsight, + buildErrorInsight, + buildFileIdentity, + buildFilePreviews, + buildOcrDocumentsFromReviewPayload, + buildOcrFilePreviews, + buildOcrSummary, + buildOcrSummaryFromDocuments, + buildReviewFilePreviewsFromReviewPayload, + extractReviewAttachmentNames, + mergeFilePreviews, + mergeFilesWithLimit, + mergeUploadAttachmentNames, + mergeUploadOcrDocuments, + normalizeOcrDocuments, + resolveAttachmentPreviewKind, + resolveDocumentPreview +} from './travelReimbursementAttachmentModel.js' +import { + ASSISTANT_DISPLAY_NAME, + FLOW_STEP_FALLBACKS, + HOT_KNOWLEDGE_QUESTIONS, + INTENT_LABELS, + SCENARIO_LABELS, + SESSION_TYPE_EXPENSE, + SESSION_TYPE_KNOWLEDGE, + aiAvatar, + buildExpenseIntentConfirmationMessage, + buildExpenseSceneSelectionMessage, + buildMessageMeta, + buildWelcomeInsight, + createMessage, + resolveKnowledgeRankLabel, + resolveKnowledgeRankTone, + sanitizeRequest, + summarizeSemanticParseDetail, + userAvatar +} from './travelReimbursementConversationModel.js' const REVIEW_RISK_LEVEL_META = { high: { @@ -75,170 +174,8 @@ const REVIEW_RISK_LEVEL_META = { suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。' } } - -const DOCUMENT_TYPE_LABELS = { - travel_ticket: '行程单/机票/车票', - flight_itinerary: '机票/航班行程单', - train_ticket: '火车/高铁票', - hotel_invoice: '酒店住宿票据', - taxi_receipt: '出租车/网约车票据', - parking_toll_receipt: '停车/通行费票据', - transport_receipt: '交通出行票据', - meal_receipt: '餐饮票据', - office_invoice: '办公用品票据', - meeting_invoice: '会议/会务票据', - training_invoice: '培训票据', - vat_invoice: '增值税发票', - receipt: '一般收据/凭证', - other: '其他单据' -} - -const EXPENSE_TYPE_LABELS = { - travel: '差旅费', - hotel: '住宿费', - transport: '交通费', - meal: '伙食费', - meeting: '会务费', - entertainment: '业务招待费', - office: '办公费', - training: '培训费', - communication: '通讯费', - welfare: '福利费', - other: '其他费用' -} - -const EXPENSE_STATUS_LABELS = { - draft: '草稿', - supplement: '待补充', - returned: '已退回', - submitted: '已提交', - review: '审批中', - approved: '已审核', - paid: '已入账' -} - -const REVIEW_SLOT_CONFIG = { - expense_type: { - title: '报销分类', - hint: '请选择本次报销分类', - status: '待确认', - icon: 'mdi mdi-shape-outline' - }, - customer_name: { - title: '关联客户', - hint: '请补充客户单位全称', - status: '待补充', - icon: 'mdi mdi-domain' - }, - time_range: { - title: '发生时间', - hint: '请按 YYYY-MM-DD 补充业务发生日期', - status: '待补充', - icon: 'mdi mdi-calendar-month-outline' - }, - location: { - title: '业务地点', - hint: '请补充业务发生地点', - status: '待补充', - icon: 'mdi mdi-map-marker-outline' - }, - merchant_name: { - title: '酒店/商户', - hint: '请补充酒店或商户名称', - status: '待补充', - icon: 'mdi mdi-storefront-outline' - }, - amount: { - title: '金额', - hint: '请补充本次费用金额', - status: '待补充', - icon: 'mdi mdi-cash' - }, - reason: { - title: '场景 / 事由', - hint: '请补充本次费用场景或事由', - status: '待补充', - icon: 'mdi mdi-text-box-outline' - }, - participants: { - title: '同行人员', - hint: '请至少填写 1 名同行人员', - status: '待补充', - icon: 'mdi mdi-account-group-outline' - }, - attachments: { - title: '票据状态', - hint: '请上传发票/收据等票据附件', - status: '未上传', - icon: 'mdi mdi-paperclip' - } -} - -const REVIEW_FALLBACK_GROUP_CODES = [ - 'other', - 'travel', - 'transport', - 'hotel', - 'meal', - 'meeting', - 'entertainment', - 'office', - 'training', - 'communication', - 'welfare' -] - -const REVIEW_CATEGORY_PRESET_OPTIONS = [ - { key: 'travel', label: '差旅费' }, - { key: 'transport', label: '交通费' }, - { key: 'hotel', label: '住宿费' }, - { key: 'meal', label: '餐费' }, - { key: 'entertainment', label: '业务招待费' }, - { key: 'other_trigger', label: '其他类型', is_other: true } -] - -const REVIEW_OTHER_CATEGORY_OPTIONS = [ - { key: 'meeting', label: '会务费' }, - { key: 'office', label: '办公费' }, - { key: 'training', label: '培训费' }, - { key: 'communication', label: '通讯费' }, - { key: 'welfare', label: '福利费' }, - { key: 'other', label: '其他费用' } -] - -const REVIEW_SCENE_OTHER_OPTION = '其他场景' -const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION] -const EXPENSE_CODE_TO_PRESET_SCENE = { - travel: '出差行程', - hotel: '住宿报销', - transport: '交通出行', - meeting: '会务活动', - entertainment: '请客户吃饭', - meal: '请客户吃饭' -} -const EXPENSE_SCENE_SELECTION_OPTIONS = [ - { key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' }, - { key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' }, - { key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' }, - { key: 'entertainment', label: '业务招待费', description: '客户接待、餐饮招待等费用', icon: 'mdi mdi-food-fork-drink' }, - { key: 'office', label: '办公费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' }, - { key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' } -] -const EXPENSE_INTENT_CONFIRMATION_ACTION = { - label: '我要报销', - description: '按报销流程继续,并选择具体费用场景', - icon: 'mdi mdi-receipt-text-check-outline', - action_type: 'confirm_expense_intent' -} -const DATE_INPUT_FORMAT = 'YYYY-MM-DD' -const MAX_ATTACHMENTS = 10 -const MAX_OCR_DOCUMENTS = 10 -const VISIBLE_ATTACHMENT_CHIPS = 2 const COMPOSER_TEXTAREA_HEIGHT = 36 const COMPOSER_MAX_ROWS = 5 -const EXPENSE_QUERY_PAGE_SIZE = 5 -const SESSION_TYPE_EXPENSE = 'expense' -const SESSION_TYPE_KNOWLEDGE = 'knowledge' const REVIEW_DRAWER_MODE_REVIEW = 'review' const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents' const REVIEW_DRAWER_MODE_RISK = 'risk' @@ -247,1356 +184,14 @@ const FLOW_STEP_STATUS_PENDING = 'pending' const FLOW_STEP_STATUS_RUNNING = 'running' const FLOW_STEP_STATUS_COMPLETED = 'completed' const FLOW_STEP_STATUS_FAILED = 'failed' -const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned']) -const FLOW_STEP_FALLBACKS = { - intent: { - title: '意图识别', - tool: 'IntentRecognizer', - runningText: '正在识别业务意图...', - completedText: '意图识别完成' - }, - extraction: { - title: '信息提取', - tool: 'SemanticExtractor', - runningText: '正在提取时间、金额、费用类型和待补项...', - completedText: '信息提取完成' - }, - ocr: { - title: '票据/OCR识别', - tool: 'OCRService', - runningText: '正在识别票据附件...', - completedText: '票据识别完成' - }, - 'expense-claim-draft': { - title: '报销草稿处理', - tool: 'database.expense_claims.save_or_submit', - runningText: '正在根据识别结果更新草稿和右侧核对信息...', - completedText: '草稿和核对信息已更新' - }, - 'expense-scene-selection': { - title: '报销场景确认', - tool: 'UserConfirmation', - runningText: '等待用户选择报销场景...', - completedText: '已进入场景选择,等待用户确认' - }, - 'expense-intent-confirmation': { - title: '报销意图确认', - tool: 'UserConfirmation', - runningText: '等待用户确认是否发起报销...', - completedText: '用户已确认报销意图' - } -} -const ASSISTANT_DISPLAY_NAME = '财务助手' - -const EXPENSE_WELCOME_QUICK_ACTIONS = [ - { - label: '发起差旅报销', - prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。', - icon: 'mdi mdi-bag-suitcase-outline' - }, - { - label: '招待费报销', - prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。', - icon: 'mdi mdi-food-fork-drink' - }, - { - label: '交通费报销', - prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。', - icon: 'mdi mdi-car-outline' - }, - { - label: '上传票据识别', - prompt: '我已准备好票据,请帮我识别并生成报销草稿。', - icon: 'mdi mdi-file-upload-outline' - }, - { - label: '查询近期报销', - prompt: '帮我查询近10天的报销记录和金额汇总。', - icon: 'mdi mdi-chart-timeline-variant' - }, - { - label: '解释报销风险', - prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。', - icon: 'mdi mdi-shield-alert-outline' - } -] - -const HOT_KNOWLEDGE_QUESTIONS = [ - '差旅住宿标准按什么规则执行?', - '酒店超标后如何申请例外报销?', - '招待费报销需要哪些凭证?', - '发票抬头不一致还能报销吗?', - '电子发票验真失败怎么处理?', - '借款多久内需要冲销?', - '预算不足还能先提交报销吗?', - '会议费和招待费如何区分?', - '跨部门项目费用应该怎么归集?', - '员工退票手续费是否可以报销?' -] -const CATEGORY_CONFIDENCE_KEYWORDS = { - travel: [/出差|差旅|行程|机票|火车|高铁|航班/], - hotel: [/住宿|酒店|宾馆|民宿/], - transport: [TRANSPORT_KEYWORD_PATTERN], - meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/], - meeting: [/会务|会议|论坛|展会|参会|会场/], - entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/], - office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/], - training: [/培训|授课|讲师|课程|签到|讲义/], - communication: [/通讯|电话|流量|话费|宽带|网络/], - welfare: [/福利|体检|团建|节日|慰问|关怀/] -} -const FLOW_MISSING_SLOT_LABELS = { - expense_type: '报销类型', - customer_name: '客户名称', - time_range: '发生时间', - location: '地点', - merchant_name: '酒店/商户', - amount: '金额', - reason: '事由说明', - participants: '参与人员', - attachments: '票据附件' -} const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意'] -let messageSeed = 0 - -function nowTime() { - return new Date().toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }) -} - -function createMessage(role, text, attachments = [], extras = {}) { - messageSeed += 1 - return { - id: `msg-${messageSeed}`, - role, - text, - attachments, - time: nowTime(), - meta: [], - citations: [], - suggestedActions: [], - suggestedActionsLocked: false, - selectedSuggestedActionKey: '', - selectedSuggestedActionLabel: '', - querySelectionLocked: false, - selectedQueryRecordId: '', - queryPayload: null, - draftPayload: null, - reviewPayload: null, - riskFlags: [], - ...extras - } -} - -function buildExpenseSceneSelectionActions(rawText) { - const originalMessage = String(rawText || '').trim() - return EXPENSE_SCENE_SELECTION_OPTIONS.map((option) => ({ - label: option.label, - description: option.description, - icon: option.icon, - action_type: 'select_expense_type', - payload: { - expense_type: option.key, - expense_type_label: option.label, - original_message: originalMessage - } - })) -} - -function buildExpenseIntentConfirmationActions(rawText) { - const originalMessage = String(rawText || '').trim() - return [{ - ...EXPENSE_INTENT_CONFIRMATION_ACTION, - payload: { - original_message: originalMessage - } - }] -} - -function buildExpenseIntentConfirmationMessage(rawText) { - const text = String(rawText || '').trim() - return [ - text - ? `我看到了「${text}」这类业务事项描述。` - : '我看到了这类业务事项描述。', - '但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。', - '如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。' - ].join('\n') -} - -function buildExpenseSceneSelectionMessage(rawText) { - const text = String(rawText || '').trim() - const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text) - const prefix = hasBusinessTime - ? '我已看到你提供了业务发生时间和报销意图。' - : '我已识别到这是报销申请。' - - return [ - `${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取和草稿生成。`, - '请先选择本次要发起的报销场景,选择后我再按对应规则继续识别和生成单据。' - ].join('\n') -} - -function buildSuggestedActionKey(action) { - const actionType = String(action?.action_type || '').trim() - const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {} - const payloadKey = String(payload.expense_type || payload.expense_type_label || action?.label || '').trim() - return `${actionType}:${payloadKey}` -} - -function formatMessageTime(value) { - if (!value) { - return nowTime() - } - - const parsed = new Date(value) - if (Number.isNaN(parsed.getTime())) { - return nowTime() - } - - return parsed.toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }) -} - -function formatSemanticEntityValue(entity) { - const normalizedValue = String(entity?.normalized_value || '').trim() - const rawValue = String(entity?.value || '').trim() - const entityType = String(entity?.type || '').trim() - - if (entityType === 'amount') { - const numericValue = Number(normalizedValue || rawValue) - if (Number.isFinite(numericValue) && numericValue > 0) { - return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` - } - } - - return rawValue || normalizedValue -} - -function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) { - if (!semanticParse || typeof semanticParse !== 'object') { - return FLOW_STEP_FALLBACKS.extraction.completedText - } - - const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : [] - const entityMap = new Map() - for (const item of entities) { - const entityType = String(item?.type || '').trim() - if (!entityType || entityMap.has(entityType)) continue - entityMap.set(entityType, item) - } - - const extractedParts = [] - const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object' - ? semanticParse.time_range_json - : {} - const startDate = String(timeRange.start_date || '').trim() - const endDate = String(timeRange.end_date || '').trim() - if (startDate) { - extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`) - } - - const amountEntity = entityMap.get('amount') - if (amountEntity) { - const amountValue = formatSemanticEntityValue(amountEntity) - if (amountValue) { - extractedParts.push(`金额 ${amountValue}`) - } - } - - const expenseTypeEntity = entityMap.get('expense_type') - if (expenseTypeEntity) { - const expenseTypeLabel = resolveExpenseTypeLabel( - String(expenseTypeEntity?.normalized_value || '').trim(), - String(expenseTypeEntity?.value || '').trim() - ) - if (expenseTypeLabel) { - extractedParts.push(`费用类型 ${expenseTypeLabel}`) - } - } - - const customerEntity = entityMap.get('customer') - if (customerEntity) { - const customerValue = formatSemanticEntityValue(customerEntity) - if (customerValue) { - extractedParts.push(`客户 ${customerValue}`) - } - } - - const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : [] - const missingLabels = missingSlots - .map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()) - .filter(Boolean) - - if (extractedParts.length && missingLabels.length) { - return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}` - } - if (extractedParts.length) { - return `已提取${extractedParts.join('、')}` - } - if (missingLabels.length) { - return `已完成信息提取;待补充 ${missingLabels.join('、')}` - } - return FLOW_STEP_FALLBACKS.extraction.completedText -} - -function formatFlowDuration(ms) { - const numericValue = Number(ms) - if (!Number.isFinite(numericValue) || numericValue < 0) { - return '--' - } - if (numericValue < 1000) { - return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s` - } - if (numericValue < 10000) { - return `${(numericValue / 1000).toFixed(1)}s` - } - return `${Math.round(numericValue / 1000)}s` -} - -function parseFlowTimestamp(value) { - const timestamp = new Date(value || '').getTime() - return Number.isFinite(timestamp) ? timestamp : 0 -} - -function resolveSemanticPhaseDurations(run) { - const runStart = parseFlowTimestamp(run?.started_at) - const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] - const firstToolStartedAt = toolCalls - .map((item) => parseFlowTimestamp(item?.created_at)) - .filter((value) => value > 0) - .sort((left, right) => left - right)[0] || 0 - const runFinishedAt = parseFlowTimestamp(run?.finished_at) - const semanticFinishedAt = firstToolStartedAt || runFinishedAt - - if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) { - return { intentMs: null, extractionMs: null } - } - - const totalMs = semanticFinishedAt - runStart - const intentMs = Math.max(120, Math.round(totalMs * 0.35)) - const extractionMs = Math.max(160, totalMs - intentMs) - return { - intentMs, - extractionMs - } -} - -function resolveToolCallDurationMs(toolCall, index, toolCalls, run) { - const explicitDuration = Number(toolCall?.duration_ms) - if (Number.isFinite(explicitDuration) && explicitDuration > 0) { - return explicitDuration - } - - const startedAt = parseFlowTimestamp(toolCall?.created_at) - if (!startedAt) { - return null - } - - const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at) - const runFinishedAt = parseFlowTimestamp(run?.finished_at) - const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0) - - if (!finishedAt || finishedAt <= startedAt) { - return null - } - - return finishedAt - startedAt -} - -function sanitizeRequest(request) { - if (!request || typeof request !== 'object') return null - - const normalized = { - id: String(request.id || '').trim(), - typeLabel: String(request.typeLabel || request.category || '').trim(), - reason: String(request.reason || request.title || '').trim(), - entity: String(request.entity || '').trim(), - city: String(request.city || request.location || '').trim(), - period: String(request.period || '').trim(), - applyTime: String(request.applyTime || request.occurredAt || '').trim(), - amount: String(request.amount || '').trim(), - node: String(request.node || '').trim(), - approval: String(request.approval || '').trim(), - travel: String(request.travel || '').trim() - } - - return Object.values(normalized).some(Boolean) ? normalized : null -} - -function resolveStatusLabel(status) { - if (status === 'succeeded') return '已完成' - if (status === 'blocked') return '已阻断' - return '失败' -} - -function resolveStatusTone(status) { - if (status === 'succeeded') return 'success' - if (status === 'blocked') return 'warning' - return 'note' -} - -function buildMessageMeta(payload, fileNames = []) { - const items = [] - - if (payload?.selected_agent) { - items.push(`Agent: ${payload.selected_agent}`) - } - - if (payload?.permission_level) { - items.push(`权限: ${payload.permission_level}`) - } - - if (payload?.trace_summary?.tool_count) { - items.push(`工具: ${payload.trace_summary.tool_count}`) - } - - if (payload?.trace_summary?.degraded) { - items.push('已降级') - } - - if (payload?.requires_confirmation) { - items.push('待确认') - } - - if (payload?.run_id) { - items.push(`Run: ${payload.run_id}`) - } - - if (fileNames.length) { - items.push(`附件: ${fileNames.length}`) - } - - return items -} - -function buildStoredMessageMeta(messageJson, attachmentNames = []) { - const payload = messageJson?.orchestrator_payload - if (payload) { - return buildMessageMeta(payload, attachmentNames) - } - - const items = [] - if (messageJson?.status) { - items.push(`状态: ${messageJson.status}`) - } - if (attachmentNames.length) { - items.push(`附件: ${attachmentNames.length}`) - } - return items -} - -function normalizeOcrDocuments(payload) { - const documents = Array.isArray(payload?.documents) ? payload.documents : [] - return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({ - filename: item.filename, - summary: item.summary, - text: String(item.text || '').slice(0, 240), - avg_score: Number(item.avg_score || 0), - line_count: Number(item.line_count || 0), - document_type: String(item.document_type || 'other').trim() || 'other', - document_type_label: String(item.document_type_label || '').trim(), - scene_code: String(item.scene_code || 'other').trim() || 'other', - scene_label: String(item.scene_label || '').trim(), - preview_kind: String(item.preview_kind || '').trim(), - preview_data_url: String(item.preview_data_url || '').trim(), - preview_url: String(item.preview_url || '').trim(), - document_fields: Array.isArray(item.document_fields) - ? item.document_fields - .map((field) => ({ - key: String(field?.key || '').trim(), - label: String(field?.label || '').trim(), - value: String(field?.value || '').trim() - })) - .filter((field) => field.key && field.label && field.value) - : [], - warnings: Array.isArray(item.warnings) ? item.warnings : [] - })) -} - -function buildOcrSummary(payload) { - return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload)) -} - -function buildOcrSummaryFromDocuments(documents) { - return (Array.isArray(documents) ? documents : []) - .slice(0, MAX_OCR_DOCUMENTS) - .map((item) => { - const filename = String(item?.filename || '').trim() - const summary = String(item?.summary || item?.text || '').trim() - if (filename && summary) { - return `${filename}:${summary}` - } - return filename || summary - }) - .filter(Boolean) - .join(';') -} - -function normalizeReviewDocumentFieldKey(label) { - const compact = String(label || '').replace(/\s+/g, '').toLowerCase() - if (!compact) return '' - if ( - ['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) => - compact.includes(token.toLowerCase()) - ) - ) { - return 'amount' - } - if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) { - return 'date' - } - if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) { - return 'merchant_name' - } - if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) { - return 'invoice_number' - } - if (compact.includes('发票代码')) { - return 'invoice_code' - } - if (compact.includes('车次') || compact.includes('航班')) { - return 'trip_no' - } - if (compact.includes('行程') || compact.includes('路线')) { - return 'route' - } - return compact -} - -function buildOcrDocumentsFromReviewPayload(reviewPayload) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => { - const fields = Array.isArray(item?.fields) - ? item.fields - .map((field) => { - const label = String(field?.label || '').trim() - const value = String(field?.value || '').trim() - if (!label || !value) { - return null - } - return { - key: normalizeReviewDocumentFieldKey(label), - label, - value - } - }) - .filter(Boolean) - : [] - - return { - filename: String(item?.filename || '').trim(), - summary: String(item?.summary || '').trim(), - text: [ - String(item?.scene_label || '').trim(), - String(item?.summary || '').trim(), - ...fields.map((field) => `${field.label}:${field.value}`) - ] - .filter(Boolean) - .join(' ') - .slice(0, 240), - avg_score: Number(item?.avg_score || 0), - document_type: String(item?.document_type || 'other').trim() || 'other', - document_type_label: resolveDocumentTypeLabel(item?.document_type), - scene_code: resolveExpenseTypeCode(item?.suggested_expense_type), - scene_label: String(item?.scene_label || '').trim(), - document_fields: fields, - warnings: Array.isArray(item?.warnings) ? item.warnings : [] - } - }).filter((item) => item.filename) -} - -function mergeUploadAttachmentNames(existingNames, incomingNames) { - const merged = [] - const seen = new Set() - - for (const value of [...(existingNames || []), ...(incomingNames || [])]) { - const normalized = String(value || '').trim() - if (!normalized || seen.has(normalized)) continue - seen.add(normalized) - merged.push(normalized) - if (merged.length >= MAX_ATTACHMENTS) { - break - } - } - - return merged -} - -function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) { - const merged = [] - const seen = new Set() - - for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) { - const filename = String(item?.filename || '').trim() - if (!filename || seen.has(filename)) continue - seen.add(filename) - merged.push(item) - if (merged.length >= MAX_OCR_DOCUMENTS) { - break - } - } - - return merged -} - -function inferPreviewKind(file) { - const mediaType = String(file?.type || '').toLowerCase() - const filename = String(file?.name || '').toLowerCase() - if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) { - return 'image' - } - if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) { - return 'pdf' - } - return 'file' -} - -function buildFilePreviews(files, previewRegistry) { - return files.map((file) => { - const kind = inferPreviewKind(file) - if (!['image', 'pdf'].includes(kind)) { - return { - filename: file.name, - kind - } - } - - const url = URL.createObjectURL(file) - previewRegistry.push(url) - return { - filename: file.name, - kind, - url - } - }) -} - -function resolveDocumentPreview(filePreviews, filename) { - if (!Array.isArray(filePreviews)) return null - const matches = filePreviews.filter((item) => item.filename === filename) - if (!matches.length) { - return null - } - return ( - matches.find((item) => item.kind === 'image' && item.url) || - matches.find((item) => item.url) || - matches[0] - ) -} - -function buildFileIdentity(file) { - return [file?.name, file?.size, file?.lastModified, file?.type].join('__') -} - -function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) { - const nextFiles = [] - const seen = new Set() - - for (const file of Array.isArray(existingFiles) ? existingFiles : []) { - const key = buildFileIdentity(file) - if (seen.has(key)) continue - seen.add(key) - nextFiles.push(file) - } - - let duplicateCount = 0 - let overflowCount = 0 - - for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) { - const key = buildFileIdentity(file) - if (seen.has(key)) { - duplicateCount += 1 - continue - } - if (nextFiles.length >= limit) { - overflowCount += 1 - continue - } - seen.add(key) - nextFiles.push(file) - } - - return { - files: nextFiles, - duplicateCount, - overflowCount - } -} - -function mergeFilePreviews(existingPreviews, incomingPreviews) { - const result = [] - const seen = new Set() - - for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) { - const key = [preview?.filename, preview?.kind].join('__') - if (!preview?.filename || seen.has(key)) continue - seen.add(key) - result.push(preview) - } - - return result -} - -function buildOcrFilePreviews(payload) { - const documents = Array.isArray(payload?.documents) ? payload.documents : [] - return documents - .map((item) => ({ - filename: String(item?.filename || '').trim(), - kind: String(item?.preview_kind || '').trim(), - url: String(item?.preview_url || item?.preview_data_url || '').trim() - })) - .filter((item) => item.filename && item.kind === 'image' && item.url) -} - -function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - return documents - .map((item) => ({ - filename: String(item?.filename || '').trim(), - kind: String(item?.preview_kind || '').trim(), - url: String(item?.preview_url || item?.preview_data_url || '').trim() - })) - .filter((item) => item.filename && item.kind === 'image' && item.url) -} - -function buildReviewFilePreviewsFromMessages(messages) { - const previews = [] - for (const message of Array.isArray(messages) ? messages : []) { - previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload)) - } - return mergeFilePreviews([], previews) -} - -function resolveAttachmentPreviewKind(metadata) { - const explicitKind = String(metadata?.preview_kind || '').trim() - if (explicitKind) { - return explicitKind - } - - const mediaType = String(metadata?.media_type || '').trim().toLowerCase() - if (mediaType.startsWith('image/')) { - return 'image' - } - if (mediaType === 'application/pdf') { - return 'pdf' - } - return '' -} - -function extractReviewAttachmentNames(reviewPayload) { - const documentNames = Array.isArray(reviewPayload?.document_cards) - ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) - : [] - if (documentNames.length) { - return documentNames - } - - const slotMap = buildReviewSlotMap(reviewPayload) - const attachmentValue = String(slotMap.attachments?.value || '').trim() - if (!attachmentValue) { - return [] - } - - return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean) -} - -function cloneReviewDocumentDrafts(items) { - return (Array.isArray(items) ? items : []).map((item) => ({ - ...item, - warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [], - fields: Array.isArray(item?.fields) - ? item.fields.map((field) => ({ - label: String(field?.label || '').trim(), - value: String(field?.value || ''), - source: String(field?.source || 'ocr').trim() || 'ocr' - })) - : [] - })) -} - -function buildReviewDocumentDrafts(reviewPayload) { - return buildReviewDocumentSummaries(reviewPayload).map((item) => ({ - index: Number(item.index || 0), - filename: String(item.filename || '').trim(), - document_type: String(item.document_type || 'other').trim() || 'other', - suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other', - scene_label: String(item.scene_label || '').trim(), - summary: String(item.summary || '').trim(), - confidenceLabel: String(item.confidenceLabel || '').trim(), - documentTypeLabel: String(item.documentTypeLabel || '').trim(), - expenseTypeLabel: String(item.expenseTypeLabel || '').trim(), - preview_kind: String(item.preview_kind || '').trim(), - preview_data_url: String(item.preview_data_url || '').trim(), - warnings: Array.isArray(item.warnings) ? [...item.warnings] : [], - fields: Array.isArray(item.fields) - ? item.fields.map((field) => ({ - label: String(field?.label || '').trim(), - value: String(field?.value || ''), - source: String(field?.source || 'ocr').trim() || 'ocr' - })) - : [] - })) -} - -function normalizeReviewDocumentComparableValue(item) { - return { - index: Number(item?.index || 0), - filename: String(item?.filename || '').trim(), - scene_label: String(item?.scene_label || '').trim(), - summary: String(item?.summary || '').trim(), - fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({ - label: String(field?.label || '').trim(), - value: String(field?.value || '').trim() - })) - } -} - -function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) { - const baseMap = new Map( - cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item]) - ) - - return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => { - const key = `${item.index}:${item.filename}` - const base = baseMap.get(key) - const changes = [] - const nextSceneLabel = String(item.scene_label || '').trim() - const baseSceneLabel = String(base?.scene_label || '').trim() - const nextSummary = String(item.summary || '').trim() - const baseSummary = String(base?.summary || '').trim() - - if (nextSceneLabel !== baseSceneLabel) { - changes.push(`票据场景:${nextSceneLabel || '待补充'}`) - } - - if (nextSummary !== baseSummary) { - changes.push(`识别摘要:${nextSummary || '待补充'}`) - } - - const baseFieldMap = new Map( - (Array.isArray(base?.fields) ? base.fields : []).map((field) => [ - String(field?.label || '').trim(), - String(field?.value || '').trim() - ]) - ) - - for (const field of Array.isArray(item.fields) ? item.fields : []) { - const label = String(field?.label || '').trim() - if (!label) continue - const nextValue = String(field?.value || '').trim() - const baseValue = baseFieldMap.get(label) || '' - if (nextValue !== baseValue) { - changes.push(`${label}:${nextValue || '待补充'}`) - } - } - - if (changes.length) { - lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`) - } - - return lines - }, []) -} - -function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) { - const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) - if (!lines.length) { - return '' - } - - return `请同步修正逐票据识别结果:\n${lines.join('\n')}` -} - -function buildReviewDocumentCorrectionContext(drafts) { - return cloneReviewDocumentDrafts(drafts).map((item) => ({ - index: item.index, - filename: item.filename, - scene_label: String(item.scene_label || '').trim(), - summary: String(item.summary || '').trim(), - fields: item.fields.map((field) => ({ - label: String(field.label || '').trim(), - value: String(field.value || '').trim() - })) - })) -} - -function buildWelcomeUserContext(user = {}) { - const username = String(user.username || '').trim() - const name = String(user.name || username || '同事').trim() - const grade = String(user.grade || '').trim() - const position = String(user.position || '').trim() - const role = String(user.role || '').trim() - const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : [] - const isAdmin = - Boolean(user.isAdmin) - || username.toLowerCase() === 'admin' - || roleCodes.some((item) => /admin|manager/i.test(String(item || ''))) - || /管理员|系统管理/.test(position) - || /管理员|系统管理/.test(role) - - const now = new Date() - const dateLine = now.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - weekday: 'long' - }) - - let honorific = name - if (isAdmin) { - honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员' - } else { - const prefix = [grade, position].filter(Boolean).join(' ') - honorific = prefix ? `${prefix} ${name}`.trim() : name - } - - return { - name, - username, - grade, - position, - role, - isAdmin, - honorific, - dateLine - } -} - -function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) { - if (sessionType === SESSION_TYPE_KNOWLEDGE) { - return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({ - label: question.length > 20 ? `${question.slice(0, 20)}…` : question, - prompt: question, - icon: 'mdi mdi-comment-question-outline' - })) - } - - if (entrySource === 'detail' && linkedRequest?.id) { - return [ - { - label: '补充当前单据票据', - prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`, - icon: 'mdi mdi-file-plus-outline' - }, - { - label: '解释本单风险', - prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`, - icon: 'mdi mdi-shield-alert-outline' - }, - ...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4) - ] - } - - return EXPENSE_WELCOME_QUICK_ACTIONS -} - -function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { - const ctx = buildWelcomeUserContext(user || {}) - const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}` - - if (sessionType === SESSION_TYPE_KNOWLEDGE) { - return [ - `${greeting}!今日是 **${ctx.dateLine}**。`, - '', - '欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。', - '', - '您可以直接输入问题,或点击下方「猜你想问」快速开始。' - ].join('\n') - } - - if (entrySource === 'detail' && linkedRequest?.id) { - return [ - `${greeting}!今日是 **${ctx.dateLine}**。`, - '', - `我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`, - '', - '如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。' - ].join('\n') - } - - return [ - `${greeting}!今日是 **${ctx.dateLine}**。`, - '', - '**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销草稿整理、待补项提醒和风险说明。', - '', - '您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。' - ].join('\n') -} - -function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { - const ctx = buildWelcomeUserContext(user || {}) - - if (sessionType === SESSION_TYPE_KNOWLEDGE) { - return { - intent: 'welcome', - metricLabel: '今日', - metricValue: ctx.dateLine.split(' ')[0] || '—', - title: '财务知识问答', - summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`, - agent: null - } - } - - return { - intent: 'welcome', - metricLabel: '助手状态', - metricValue: '待您吩咐', - title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心', - summary: - entrySource === 'detail' && linkedRequest?.id - ? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。` - : `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`, - agent: null - } -} - -function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { - return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], { - assistantName: ASSISTANT_DISPLAY_NAME, - isWelcome: true, - welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) - }) -} - -function resolveInitialSessionType(conversation) { - const stateJson = conversation?.state_json || conversation?.stateJson || {} - const sessionType = String(stateJson?.session_type || '').trim() - return sessionType || SESSION_TYPE_EXPENSE -} - -function buildInitialInsightFromConversation(conversation) { - const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] - for (let index = rawMessages.length - 1; index >= 0; index -= 1) { - const item = rawMessages[index] - const messageJson = item?.message_json || item?.messageJson || {} - const orchestratorPayload = messageJson?.orchestrator_payload || null - if (!orchestratorPayload) continue - const attachmentNames = Array.isArray(messageJson?.attachment_names) - ? messageJson.attachment_names.filter(Boolean) - : [] - return buildAgentInsight( - orchestratorPayload, - attachmentNames, - buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload) - ) - } - return null -} - -function resolveInitialConversationId(conversation) { - return String(conversation?.conversation_id || conversation?.conversationId || '').trim() -} - -function resolveInitialDraftClaimId(conversation) { - return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() -} - -function resolveKnowledgeRankLabel(index) { - return String(index + 1) -} - -function resolveKnowledgeRankTone(index) { - if (index === 0) return 'gold' - if (index === 1) return 'silver' - if (index === 2) return 'bronze' - return 'default' -} - -function parseConversationMessageSequence(message) { - const messageJson = message?.message_json || message?.messageJson || {} - const sequence = Number.parseInt(messageJson?.sequence, 10) - return Number.isFinite(sequence) && sequence > 0 ? sequence : null -} - -function parseConversationMessageTime(message) { - const rawValue = message?.created_at || message?.createdAt || '' - const timestamp = new Date(rawValue).getTime() - return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER -} - -function resolveConversationMessageRolePriority(message) { - return String(message?.role || '').trim() === 'user' ? 0 : 1 -} - -function sortConversationMessages(messages) { - return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => { - const leftSequence = parseConversationMessageSequence(left) - const rightSequence = parseConversationMessageSequence(right) - if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) { - return leftSequence - rightSequence - } - - const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right) - if (timeDiff !== 0) { - return timeDiff - } - - const leftRunId = String(left?.run_id || left?.runId || '').trim() - const rightRunId = String(right?.run_id || right?.runId || '').trim() - if (leftRunId && rightRunId && leftRunId === rightRunId) { - const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right) - if (roleDiff !== 0) { - return roleDiff - } - } - - return String(left?.id || '').localeCompare(String(right?.id || '')) - }) -} - -function normalizeInitialConversationMessages(conversation) { - const rawMessages = sortConversationMessages(conversation?.messages) - - const restoredMessages = rawMessages.map((item) => { - const messageJson = item?.message_json || item?.messageJson || {} - const attachmentNames = Array.isArray(messageJson?.attachment_names) - ? messageJson.attachment_names.filter(Boolean) - : [] - const orchestratorPayload = messageJson?.orchestrator_payload || null - const result = orchestratorPayload?.result || {} - - return createMessage(item.role, item.content, attachmentNames, { - id: `restored-${item.id || ++messageSeed}`, - time: formatMessageTime(item.created_at || item.createdAt), - meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [], - citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [], - suggestedActions: - item.role === 'assistant' && Array.isArray(result?.suggested_actions) - ? result.suggested_actions - : [], - queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, - draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, - reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, - riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] - }) - }) - return markResolvedSuggestedActionMessages(restoredMessages) -} - -function normalizeSnapshotMessage(message) { - const extras = message && typeof message === 'object' ? { ...message } : {} - const role = String(extras.role || 'assistant').trim() || 'assistant' - const text = String(extras.text || '') - const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : [] - delete extras.role - delete extras.text - delete extras.attachments - return createMessage(role, text, attachments, extras) -} - -function normalizeSnapshotMessages(messages) { - return Array.isArray(messages) - ? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage)) - : [] -} - -function serializeSessionMessages(messages) { - return (Array.isArray(messages) ? messages : []).map((message) => ({ - id: message.id, - role: message.role, - text: message.text, - attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [], - time: message.time, - meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [], - metaTone: message.metaTone || '', - citations: Array.isArray(message.citations) ? message.citations : [], - suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], - suggestedActionsLocked: Boolean(message.suggestedActionsLocked), - selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''), - selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''), - querySelectionLocked: Boolean(message.querySelectionLocked), - selectedQueryRecordId: String(message.selectedQueryRecordId || ''), - queryPayload: message.queryPayload || null, - draftPayload: message.draftPayload || null, - reviewPayload: message.reviewPayload || null, - riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [], - assistantName: message.assistantName || '', - isWelcome: Boolean(message.isWelcome), - welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : [] - })) -} - -function hasMeaningfulSessionMessages(messages) { - return (Array.isArray(messages) ? messages : []).some((message) => { - if (!message || message.isWelcome) { - return false - } - if (message.role === 'user') { - return true - } - return Boolean( - String(message.text || '').trim() - || (Array.isArray(message.suggestedActions) && message.suggestedActions.length) - || message.reviewPayload - || message.queryPayload - || message.draftPayload - ) - }) -} - -function hasActiveSuggestedActionMessage(messages) { - return (Array.isArray(messages) ? messages : []).some( - (message) => - message?.role === 'assistant' - && Array.isArray(message.suggestedActions) - && message.suggestedActions.length > 0 - && !message.suggestedActionsLocked - ) -} - -function resolveConversationUpdatedAt(conversation) { - const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime() - return Number.isFinite(timestamp) ? timestamp : 0 -} - -function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) { - if (!persistedState) { - return false - } - if (!conversation) { - return true - } - if (hasActiveSuggestedActionMessage(persistedState.messages)) { - return true - } - const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0) - return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation) -} - -function markResolvedSuggestedActionMessages(messages) { - const items = Array.isArray(messages) ? messages : [] - const selectedLabels = new Set() - - for (const message of items) { - if (message?.role !== 'user') { - continue - } - const text = String(message.text || '').trim() - const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[::]\s*([^\n\r]+)/) - if (selectedMatch?.[1]) { - selectedLabels.add(selectedMatch[1].trim()) - } else if (text === '我要报销') { - selectedLabels.add(text) - } - } - - if (!selectedLabels.size) { - return items - } - - return items.map((message) => { - if ( - message?.role !== 'assistant' - || message.suggestedActionsLocked - || !Array.isArray(message.suggestedActions) - || !message.suggestedActions.length - ) { - return message - } - - const selectedAction = message.suggestedActions.find((action) => - selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim()) - ) - if (!selectedAction) { - return message - } - - return { - ...message, - suggestedActionsLocked: true, - selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction), - selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim() - } - }) -} - -function cloneReviewEditFields(fields) { - const items = Array.isArray(fields) ? fields : [] - return items.map((item) => ({ - key: String(item?.key || '').trim(), - label: String(item?.label || '').trim(), - value: String(item?.value || ''), - placeholder: String(item?.placeholder || ''), - required: Boolean(item?.required), - field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text', - group: String(item?.group || 'basic').trim() || 'basic' - })) -} - -function buildReviewFormValues(fields) { - return cloneReviewEditFields(fields).reduce((result, item) => { - if (!item.key) { - return result - } - result[item.key] = String(item.value || '').trim() - return result - }, {}) -} function buildBusinessTimeContextFromReviewValues(values = {}) { - const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim() - if (!timeText) { - return null - } - - const matchedDates = timeText.match(/\d{4}-\d{2}-\d{2}/g) || [] - if (!matchedDates.length) { - return null - } - const startDate = matchedDates[0] - const endDate = matchedDates[matchedDates.length - 1] || startDate - if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { - return null - } - const displayValue = startDate === endDate ? startDate : `${startDate} 至 ${endDate}` - return { - mode: startDate === endDate ? 'single' : 'range', - start_date: startDate, - end_date: endDate, - display_value: displayValue - } + return buildBusinessTimeContextFromReviewValuesModel(values) } function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) { - if (!reviewPayload || typeof reviewPayload !== 'object') { - return {} - } - - const fallbackState = buildInlineReviewState(reviewPayload) - const candidateState = inlineState || fallbackState - const hasCandidateValue = Object.values(candidateState || {}).some((value) => { - if (typeof value === 'number') return value > 0 - return Boolean(String(value || '').trim()) - }) - const state = hasCandidateValue ? candidateState : fallbackState - const fields = mergeInlineReviewFields(reviewPayload.edit_fields || [], state) - const values = buildReviewFormValues(fields) - const slotMap = buildReviewSlotMap(reviewPayload) - const inheritedTimeRange = String( - slotMap.time_range?.normalized_value || - slotMap.time_range?.value || - values.time_range || - values.business_time || - values.occurred_date || - '' - ).trim() - if (inheritedTimeRange) { - values.time_range = values.time_range || inheritedTimeRange - values.business_time = values.business_time || inheritedTimeRange - } - - const businessTimeContext = buildBusinessTimeContextFromReviewValues(values) - return { - review_form_values: values, - ...(businessTimeContext ? { business_time_context: businessTimeContext } : {}) - } + return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState) } function buildReviewCorrectionMessage(fields) { @@ -1610,208 +205,12 @@ function buildReviewCorrectionMessage(fields) { return lines.join('\n') } -function buildReviewEditFieldMap(fields) { - return cloneReviewEditFields(fields).reduce((result, item) => { - if (!item.key) return result - result[item.key] = item - return result - }, {}) -} - -function createEmptyInlineReviewState() { - return { - occurred_date: '', - amount: '', - transport_type: '', - scene_label: '', - reason_value: '', - customer_name: '', - location: '', - merchant_name: '', - participants: '', - attachment_names: '', - attachment_count: 0, - pending_attachment_count: 0, - expense_type: '' - } -} - function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { - const expenseType = resolveExpenseTypeCode( - inlineState?.expense_type || - buildReviewSlotMap(reviewPayload).expense_type?.normalized_value || - buildReviewSlotMap(reviewPayload).expense_type?.value || - '' - ) - if (['travel', 'hotel', 'transport'].includes(expenseType)) { - return true - } - - return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => { - const documentType = String(item?.document_type || '').trim().toLowerCase() - const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '') - return ( - ['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) || - ['travel', 'hotel', 'transport'].includes(suggestedType) - ) - }) + return isTravelReviewPayloadModel(reviewPayload, inlineState) } function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - const labels = [] - - const appendLabel = (label) => { - if (label && !labels.includes(label)) { - labels.push(label) - } - } - - for (const item of documents) { - const documentType = String(item?.document_type || '').trim().toLowerCase() - const text = [ - item?.filename, - item?.summary, - item?.scene_label, - item?.suggested_expense_type, - ...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : []) - ].join(' ') - const compact = text.replace(/\s+/g, '') - - if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) { - appendLabel('飞机') - } else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) { - appendLabel('火车/高铁') - } else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) { - appendLabel('打车/网约车') - } - } - - const fallback = String(fallbackText || '').replace(/\s+/g, '') - if (!labels.length) { - if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机') - if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁') - if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车') - } - - return labels.join('、') -} - -function buildClientTimeContext() { - const now = new Date() - const locale = - typeof navigator !== 'undefined' && typeof navigator.language === 'string' - ? navigator.language - : 'zh-CN' - - return { - client_now_iso: now.toISOString(), - client_timezone_offset_minutes: now.getTimezoneOffset(), - client_locale: locale - } -} - -function formatDraftApplyTime(date = new Date()) { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - return `${year}-${month}-${day} ${hours}:${minutes}` -} - -function formatDateInputValue(date = new Date()) { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function buildDraftSavedPayload({ - draftPayload, - reviewPayload, - inlineState, - linkedRequest, - currentUser -}) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - const riskItems = buildReviewRiskItems(reviewPayload) - const missingItems = resolveReviewMissingSlotCards(reviewPayload) - const typeCode = resolveExpenseTypeCode(inlineState?.expense_type) - const amountNumber = parseAmountNumber(inlineState?.amount) - const location = String(inlineState?.location || linkedRequest?.city || '').trim() - const customerName = String(inlineState?.customer_name || '').trim() - const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim() - const title = - String(inlineState?.reason_value || '').trim() - || String(inlineState?.scene_label || '').trim() - || String(draftPayload?.title || '').trim() - || `${typeLabel}报销草稿` - const sceneLabel = - String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel - const attachmentSummary = documents.length - ? `${documents.length} 条识别票据 / ${documents.length} 份材料` - : String(inlineState?.attachment_names || '').trim() - ? '1 条识别票据 / 1 份材料' - : '待上传票据' - - return { - claimId: String(draftPayload?.claim_id || '').trim(), - claimNo: String(draftPayload?.claim_no || '').trim(), - status: String(draftPayload?.status || '').trim(), - approvalStage: String(draftPayload?.approval_stage || '').trim(), - person: String(currentUser?.name || '').trim() || '当前用户', - dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门', - entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.', - typeCode, - typeLabel, - detailVariant: typeCode === 'travel' ? 'travel' : 'general', - title, - sceneLabel, - sceneTarget: location || customerName || '待补充', - location, - relatedCustomer: customerName, - occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充', - applyTime: formatDraftApplyTime(), - amount: amountNumber === null ? 0 : amountNumber, - secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', - secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据', - secondaryStatusTone: documents.length ? 'warning' : 'neutral', - riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'), - attachmentSummary, - expenseTableSummary: documents.length - ? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认` - : '当前尚未上传票据,请在报销页继续补充附件', - note: String(draftPayload?.status || '').trim() === 'submitted' - ? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。' - : '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。' - } -} - -function resolveReviewRecognizedSlotCards(reviewPayload) { - return Array.isArray(reviewPayload?.slot_cards) - ? reviewPayload.slot_cards.filter((item) => item.status !== 'missing') - : [] -} - -function resolveReviewMissingSlotCards(reviewPayload) { - return Array.isArray(reviewPayload?.slot_cards) - ? reviewPayload.slot_cards.filter((item) => item.status === 'missing') - : [] -} - -function resolveReviewExtraMissingLabels(reviewPayload) { - const labels = Array.isArray(reviewPayload?.missing_slots) - ? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean) - : [] - if (!labels.length) return [] - - const slotLabels = new Set( - (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []) - .map((item) => String(item?.label || item?.key || '').trim()) - .filter(Boolean) - ) - return labels.filter((label) => !slotLabels.has(label)) + return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText) } function resolveReviewRiskBriefs(reviewPayload) { @@ -1822,813 +221,6 @@ function resolveReviewRiskBriefs(reviewPayload) { }) } -function formatConfidenceLabel(value) { - const score = Number(value || 0) - if (!score) return '待补充' - return `${Math.round(score * 100)}%` -} - -function resolveDocumentTypeLabel(type) { - return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other -} - -function resolveExpenseTypeLabel(type, fallbackLabel = '') { - const normalized = String(type || '').trim() - return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other -} - -function buildReviewRecognizedLines(reviewPayload) { - return resolveReviewRecognizedSlotCards(reviewPayload) - .filter((item) => String(item?.value || '').trim()) - .map((item) => `${item.label}:${item.value}`) -} - -function buildReviewSlotMap(reviewPayload) { - return Object.fromEntries( - (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item]) - ) -} - -function resolveExpenseTypeCode(value) { - const normalized = String(value || '').trim() - if (!normalized) return 'other' - if (EXPENSE_TYPE_LABELS[normalized]) return normalized - const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized) - return matched?.[0] || 'other' -} - -function isValidIsoDateString(value) { - const normalized = String(value || '').trim() - if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { - return false - } - - const [yearText, monthText, dayText] = normalized.split('-') - const year = Number(yearText) - const month = Number(monthText) - const day = Number(dayText) - - if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { - return false - } - - const candidate = new Date(Date.UTC(year, month - 1, day)) - return ( - candidate.getUTCFullYear() === year && - candidate.getUTCMonth() === month - 1 && - candidate.getUTCDate() === day - ) -} - -function parseAmountNumber(value) { - const normalized = String(value || '') - .replace(/[,,\s]/g, '') - .replace(/[¥¥]/g, '') - .replace(/元/g, '') - .trim() - - if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) { - return null - } - - const amount = Number(normalized) - return Number.isFinite(amount) ? amount : null -} - -function normalizeAmountValue(value) { - const amount = parseAmountNumber(value) - if (amount === null) { - return '' - } - return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元` -} - -function extractAmountInputValue(value) { - const amount = parseAmountNumber(value) - if (amount === null) { - return String(value || '').trim() - } - return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '') -} - -function formatAmountDisplay(value) { - const amount = parseAmountNumber(value) - if (amount === null) { - return String(value || '').trim() - } - - return new Intl.NumberFormat('zh-CN', { - style: 'currency', - currency: 'CNY', - minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, - maximumFractionDigits: Number.isInteger(amount) ? 0 : 2 - }).format(amount) -} - -function normalizeExpenseQueryStatusGroup(item) { - if (!item || typeof item !== 'object') { - return null - } - - const rawCount = Number(item.count || 0) - return { - key: String(item.key || 'other').trim() || 'other', - label: String(item.label || '其他状态').trim() || '其他状态', - count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0 - } -} - -function normalizeExpenseQueryRecord(item) { - if (!item || typeof item !== 'object') { - return null - } - - const amount = Number(item.amount || 0) - const amountValue = Number.isFinite(amount) ? amount : 0 - const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销' - const reason = String(item.reason || '').trim() - const documentDate = String(item.document_date || '').trim() - const occurredAt = String(item.occurred_at || '').trim() - - return { - claimId: String(item.claim_id || '').trim(), - claimNo: String(item.claim_no || '').trim() || '未编号', - employeeName: String(item.employee_name || '').trim(), - expenseType: String(item.expense_type || '').trim(), - expenseTypeLabel, - amount: amountValue, - amountDisplay: formatAmountDisplay(amountValue), - status: String(item.status || '').trim(), - statusLabel: String(item.status_label || '处理中').trim() || '处理中', - statusGroup: String(item.status_group || 'other').trim() || 'other', - statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态', - approvalStage: String(item.approval_stage || '').trim(), - documentDate, - occurredAt, - reason, - location: String(item.location || '').trim(), - summary: reason || `${expenseTypeLabel}报销`, - dateDisplay: documentDate || occurredAt || '待补充日期' - } -} - -function resolveExpenseStatusGroup(status) { - const normalized = String(status || '').trim() - if (['draft', 'supplement', 'returned'].includes(normalized)) { - return { key: 'draft', label: normalized === 'draft' ? '草稿' : '待完善' } - } - if (['submitted', 'review'].includes(normalized)) { - return { key: 'in_progress', label: '审批中' } - } - if (['approved', 'paid'].includes(normalized)) { - return { key: 'completed', label: '已完成' } - } - return { key: 'other', label: '其他状态' } -} - -function formatQueryRecordDate(value) { - const text = String(value || '').trim() - if (!text) return '' - return text.includes('T') ? text.split('T')[0] : text.slice(0, 10) -} - -function buildQueryRecordFromClaim(claim) { - if (!claim || typeof claim !== 'object') { - return null - } - const claimId = String(claim.id || claim.claim_id || '').trim() - if (!claimId) { - return null - } - - const status = String(claim.status || '').trim() - const statusGroup = resolveExpenseStatusGroup(status) - return { - claim_id: claimId, - claim_no: String(claim.claim_no || claim.claimNo || '').trim() || '未编号', - employee_name: String(claim.employee_name || claim.employeeName || '').trim(), - expense_type: String(claim.expense_type || claim.expenseType || '').trim(), - expense_type_label: EXPENSE_TYPE_LABELS[String(claim.expense_type || claim.expenseType || '').trim()] || String(claim.expense_type || claim.expenseType || '报销').trim(), - amount: Number(claim.amount || 0), - status, - status_label: EXPENSE_STATUS_LABELS[status] || statusGroup.label, - status_group: statusGroup.key, - status_group_label: statusGroup.label, - approval_stage: String(claim.approval_stage || claim.approvalStage || '').trim(), - document_date: formatQueryRecordDate(claim.submitted_at || claim.submittedAt || claim.created_at || claim.createdAt || claim.occurred_at || claim.occurredAt), - occurred_at: formatQueryRecordDate(claim.occurred_at || claim.occurredAt), - reason: String(claim.reason || '').trim(), - location: String(claim.location || '').trim() - } -} - -function buildDraftAssociationQueryPayload(claims) { - const records = (Array.isArray(claims) ? claims : []) - .filter((claim) => ASSOCIATABLE_CLAIM_STATUSES.has(String(claim?.status || '').trim())) - .map(buildQueryRecordFromClaim) - .filter(Boolean) - - const statusGroups = records.reduce((groups, record) => { - const key = String(record.status_group || 'other') - const existing = groups.get(key) || { - key, - label: String(record.status_group_label || '其他状态'), - count: 0 - } - existing.count += 1 - groups.set(key, existing) - return groups - }, new Map()) - - return normalizeExpenseQueryPayload({ - result_type: 'expense_claim_list', - title: '选择关联草稿', - scope_label: '可关联草稿', - selection_mode: 'draft_association', - empty_text: '当前没有可关联的草稿单据。', - recent_window_applied: false, - record_count: records.length, - preview_count: records.length, - total_amount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0), - status_groups: Array.from(statusGroups.values()), - records - }) -} - -function normalizeExpenseQueryPayload(payload) { - if (!payload || typeof payload !== 'object') { - return null - } - - const resultType = String(payload.result_type || '').trim() - if (resultType && resultType !== 'expense_claim_list') { - return null - } - - const records = (Array.isArray(payload.records) ? payload.records : []) - .map(normalizeExpenseQueryRecord) - .filter(Boolean) - const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : []) - .map(normalizeExpenseQueryStatusGroup) - .filter(Boolean) - - const rawRecordCount = Number(payload.record_count || 0) - const rawPreviewCount = Number(payload.preview_count || records.length) - const rawOlderRecordCount = Number(payload.older_record_count || 0) - const totalAmount = Number(payload.total_amount || 0) - const rawWindowDays = Number(payload.window_days || 0) - const windowStartDate = String(payload.window_start_date || '').trim() - const windowEndDate = String(payload.window_end_date || '').trim() - - return { - resultType: 'expense_claim_list', - scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单', - selectionMode: String(payload.selection_mode || payload.selectionMode || '').trim(), - selectionLocked: Boolean(payload.selection_locked || payload.selectionLocked), - selectedClaimId: String(payload.selected_claim_id || payload.selectedClaimId || '').trim(), - title: String(payload.title || '').trim(), - emptyText: String(payload.empty_text || payload.emptyText || '').trim(), - recentWindowApplied: Boolean(payload.recent_window_applied), - windowDays: - payload.window_days === null || payload.window_days === undefined || payload.window_days === '' - ? null - : (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null), - windowStartDate: windowStartDate || '', - windowEndDate: windowEndDate || '', - recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0, - previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length, - olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0, - hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more), - totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0, - statusGroups, - records, - currentPage: 1 - } -} - -function buildExpenseQueryWindowLabel(queryPayload) { - if (!queryPayload) { - return '' - } - - if (queryPayload.selectionMode === 'draft_association') { - return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。' - } - - if (queryPayload.windowStartDate && queryPayload.windowEndDate) { - return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}` - } - - if (queryPayload.recentWindowApplied && queryPayload.windowDays) { - return `近 ${queryPayload.windowDays} 日内` - } - - return '当前条件下' -} - -function getExpenseQueryTotalPages(queryPayload) { - const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0 - return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE)) -} - -function getExpenseQueryActivePage(queryPayload) { - const totalPages = getExpenseQueryTotalPages(queryPayload) - const rawPage = Number(queryPayload?.currentPage || 1) - if (!Number.isFinite(rawPage)) { - return 1 - } - return Math.min(Math.max(1, Math.round(rawPage)), totalPages) -} - -function getExpenseQueryVisibleRecords(queryPayload) { - const records = Array.isArray(queryPayload?.records) ? queryPayload.records : [] - const activePage = getExpenseQueryActivePage(queryPayload) - const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE - return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE) -} - -function buildExpenseQueryHint(queryPayload) { - if (!queryPayload) { - return '' - } - - if (queryPayload.selectionMode === 'draft_association') { - if (queryPayload.selectionLocked && queryPayload.selectedClaimId) { - return '已选择关联草稿,附件将按该单据继续识别和归集。' - } - return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。' - } - - const parts = [] - const windowText = buildExpenseQueryWindowLabel(queryPayload) - - if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) { - parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`) - } - - if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) { - parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`) - } - - if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) { - parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`) - } - - return parts.join('。') -} - -function countReviewPendingItems(reviewPayload) { - return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length -} - -function countReviewRiskItems(reviewPayload) { - return resolveReviewRiskBriefs(reviewPayload).length -} - -function buildReviewHeadline(reviewPayload) { - if (countReviewPendingItems(reviewPayload)) { - return '待补充信息' - } - if (reviewPayload?.can_proceed) { - return '识别结果已整理完成' - } - return '识别结果摘要' -} - -function buildReviewSubline(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - - if (pendingCount) { - return `请先展开查看 ${pendingCount} 项待补充内容,再决定继续处理、修改信息或保存草稿。` - } - if (reviewPayload?.can_proceed) { - return '当前关键信息已基本齐全,展开确认无误后可以继续下一步。' - } - return '已为您整理本轮识别结果,展开后可查看当前识别摘要。' -} - -function buildReviewStateLabel(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - if (pendingCount) return `待补充 ${pendingCount} 项` - if (reviewPayload?.can_proceed) return '可继续处理' - return '已识别' -} - -function buildReviewStateTone(reviewPayload) { - return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload) - ? 'ready' - : 'pending' -} - -function buildReviewDisclosureTitle(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - if (pendingCount) { - return `当前有 ${pendingCount} 项待补充,点击展开查看` - } - return '当前信息已齐全,可展开查看识别摘要' -} - -function buildReviewDisclosureHint(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - if (pendingCount) { - return '展开后可查看待补充字段和处理建议' - } - return '展开后可查看本轮已识别的关键信息' -} - -function shouldOpenReviewDisclosure(reviewPayload) { - return !countReviewPendingItems(reviewPayload) -} - -function buildReviewTodoSectionTitle(reviewPayload) { - return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息' -} - -function buildReviewTodoSectionMeta(reviewPayload) { - const count = buildReviewTodoItems(reviewPayload).length - if (countReviewPendingItems(reviewPayload)) { - return count ? `${count} 项` : '待确认' - } - return count ? `${count} 项` : '已齐全' -} - -function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { - if (slotKey === 'customer_name') { - return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户' - } - if (slotKey === 'participants') return '缺少同行人员' - if (slotKey === 'attachments') return '票据状态待补充' - if (slotKey === 'amount') return '金额待确认' - if (slotKey === 'time_range') return '发生时间待确认' - if (slotKey === 'reason') return '场景 / 事由待补充' - if (slotKey === 'expense_type') return '报销类型待确认' - if (slotKey === 'location') return '业务地点待补充' - if (slotKey === 'merchant_name') return '酒店/商户待补充' - return '仍有信息待补充' -} - -function buildReviewAlertChips(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() - const chips = [] - - for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) { - chips.push({ - key: item.key, - label: buildReviewAlertLabel(item.key, expenseTypeLabel), - tone: 'warning' - }) - } - - if (chips.length < 3) { - for (const label of resolveReviewExtraMissingLabels(reviewPayload)) { - chips.push({ - key: label, - label, - tone: 'warning' - }) - if (chips.length >= 3) break - } - } - - if (chips.length < 3) { - for (const risk of resolveReviewRiskBriefs(reviewPayload)) { - if (chips.some((item) => item.label === risk.title)) continue - chips.push({ - key: risk.title, - label: risk.title, - tone: risk.level === 'high' ? 'danger' : 'warning' - }) - if (chips.length >= 3) break - } - } - - if (!chips.length && reviewPayload?.can_proceed) { - chips.push({ - key: 'ready', - label: '当前识别信息已可继续处理', - tone: 'success' - }) - } - - return chips -} - -function buildReviewTodoItems(reviewPayload) { - const missingItems = resolveReviewMissingSlotCards(reviewPayload) - const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload) - if (missingItems.length || extraMissingLabels.length) { - return [ - ...missingItems.map((item) => { - const config = REVIEW_SLOT_CONFIG[item.key] || {} - return { - key: item.key, - icon: config.icon || 'mdi mdi-form-select', - title: config.title || item.label, - hint: item.hint || config.hint || `请补充${item.label}`, - status: config.status || '待补充', - tone: 'warning' - } - }), - ...extraMissingLabels.map((label, index) => ({ - key: `extra-missing-${index}-${label}`, - icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline', - title: label, - hint: label.includes('必须') - ? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。' - : '可以继续补充该材料;如暂时没有,也可以按当前信息处理。', - status: label.includes('必须') ? '必须补齐' : '可选补充', - tone: 'warning' - })) - ] - } - - return resolveReviewRecognizedSlotCards(reviewPayload) - .filter((item) => String(item?.value || '').trim()) - .slice(0, 3) - .map((item) => { - const config = REVIEW_SLOT_CONFIG[item.key] || {} - return { - key: item.key, - icon: config.icon || 'mdi mdi-check-circle-outline', - title: config.title || item.label, - hint: `已识别:${item.value}`, - status: '已识别', - tone: 'ready' - } - }) -} - -function resolveReviewPrimaryAction(reviewPayload) { - return ( - (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( - (item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || '')) - ) || null - ) -} - -function resolveReviewSubmitActions(reviewPayload) { - return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => { - const actionType = String(item?.action_type || '').trim() - return actionType && !['cancel_review', 'edit_review'].includes(actionType) - }) -} - -function resolveReviewEditAction(reviewPayload) { - return ( - (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( - (item) => String(item?.action_type || '') === 'edit_review' - ) || null - ) -} - -function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) { - const action = resolveReviewPrimaryAction(reviewPayload) - if (!action) return '确认' - if (action.action_type === 'save_draft') { - return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿' - } - if (action.action_type === 'next_step') { - return '继续下一步' - } - if (action.action_type === 'link_to_existing_draft') { - return action.label || '关联到现有草稿' - } - if (action.action_type === 'create_new_claim_from_documents') { - return action.label || '单独建立报销单' - } - return action.label || '确认' -} - -function buildReviewIntentText(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const expenseType = String(slotMap.expense_type?.value || '').trim() - if (expenseType) { - return `报销一笔${expenseType}` - } - return '发起一笔报销' -} - -function buildReviewSceneValue(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim() - const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim() - return inferPresetSceneFromReview(reviewPayload, reason, expenseType) -} - -function matchPresetSceneFromReason(reason) { - const compactReason = String(reason || '').trim().replace(/\s+/g, '') - if (!compactReason) { - return '' - } - if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) { - return '请客户吃饭' - } - if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) { - const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, ''))) - if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) { - return matchedPreset - } - } - if (/出差|差旅/.test(compactReason)) { - return '出差行程' - } - if (/酒店|住宿/.test(compactReason)) { - return '住宿报销' - } - if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) { - return '交通出行' - } - if (/会务|会议|参会|论坛|展会/.test(compactReason)) { - return '会务活动' - } - return '' -} - -function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') { - const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)] - if (fromCode) { - return fromCode - } - - const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '') - if (!compactLabel) { - return '' - } - if (/差旅|出差/.test(compactLabel)) { - return '出差行程' - } - if (/住宿|酒店/.test(compactLabel)) { - return '住宿报销' - } - if (/交通/.test(compactLabel)) { - return '交通出行' - } - if (/招待|餐饮|餐费|伙食/.test(compactLabel)) { - return '请客户吃饭' - } - if (/会务|会议/.test(compactLabel)) { - return '会务活动' - } - return '' -} - -function mapExpenseTypeLabelToPresetScene(expenseType) { - const code = resolveExpenseTypeCode(expenseType) - if (EXPENSE_CODE_TO_PRESET_SCENE[code]) { - return EXPENSE_CODE_TO_PRESET_SCENE[code] - } - - const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '') - if (!compactLabel) { - return '' - } - if (compactLabel.includes('差旅') || compactLabel.includes('出差')) { - return '出差行程' - } - if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) { - return '住宿报销' - } - if (compactLabel.includes('交通')) { - return '交通出行' - } - if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) { - return '请客户吃饭' - } - if (compactLabel.includes('会务') || compactLabel.includes('会议')) { - return '会务活动' - } - return matchPresetSceneFromReason(expenseType) -} - -function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - if (documents.length) { - const votes = new Map() - for (const document of documents) { - const preset = - mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type) - || mapExpenseTypeLabelToPresetScene(document.suggested_expense_type) - if (!preset) { - continue - } - votes.set(preset, (votes.get(preset) || 0) + 1) - } - if (votes.size) { - return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0] - } - } - - const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : [] - if (claimGroups.length === 1) { - const group = claimGroups[0] - const preset = - mapExpenseTypeLabelToPresetScene(group.expense_type) - || mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type) - if (preset) { - return preset - } - } - - const fromReason = matchPresetSceneFromReason(reasonValue) - if (fromReason) { - return fromReason - } - - const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType) - if (fromExpenseType) { - return fromExpenseType - } - - if (String(reasonValue || '').trim()) { - return REVIEW_SCENE_OTHER_OPTION - } - return '待补充' -} - -function formatReviewSceneDisplayValue(inlineState) { - const scene = String(inlineState?.scene_label || '').trim() - if (!scene || scene === '待补充') { - return '待补充' - } - if (scene === REVIEW_SCENE_OTHER_OPTION) { - const detail = String(inlineState?.reason_value || '').trim() - if (!detail) { - return REVIEW_SCENE_OTHER_OPTION - } - return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}:${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}:${detail}` - } - return scene -} - -function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) { - return inferPresetSceneFromReview(reviewPayload, reason, expenseType) -} - -function buildInlineReviewState(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields) - const attachmentNames = String( - editFieldMap.attachment_names?.value || - slotMap.attachments?.value || - (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '') - ).trim() - const attachmentCount = Array.isArray(reviewPayload?.document_cards) - ? reviewPayload.document_cards.length - : attachmentNames - ? attachmentNames.split('、').filter(Boolean).length - : 0 - const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim() - const reasonValue = String( - editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || '' - ).trim() - const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType) - const transportType = String( - editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue) - ).trim() - - return { - occurred_date: String( - editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || '' - ).trim(), - amount: normalizeAmountValue( - String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() - ), - transport_type: transportType, - scene_label: sceneLabel, - reason_value: - sceneLabel === REVIEW_SCENE_OTHER_OPTION - ? reasonValue - : String(slotMap.reason?.raw_value || '').trim() || reasonValue, - customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(), - location: String( - editFieldMap.business_location?.value || - editFieldMap.location?.value || - slotMap.location?.normalized_value || - slotMap.location?.value || - '' - ).trim(), - merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(), - participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(), - attachment_names: attachmentNames, - attachment_count: attachmentCount, - pending_attachment_count: 0, - expense_type: expenseType - } -} - -function buildReviewAttachmentStatus(reviewPayload) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - if (!documents.length) return '未上传' - return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份` -} - -function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') { - const slotMap = buildReviewSlotMap(reviewPayload) - const slot = slotMap[slotKey] - return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing' -} - function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) { const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0)) const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0)) @@ -2778,149 +370,6 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi return cards } -function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) { - const slotMap = buildReviewSlotMap(reviewPayload) - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - - return [ - String(inlineState.reason_value || '').trim(), - String(inlineState.scene_label || '').trim(), - String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(), - ...documents.map((item) => - [item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])] - .filter(Boolean) - .join(' ') - ) - ] - .filter(Boolean) - .join(' ') - .toLowerCase() -} - -function resolveReviewCategoryTextScore(text, categoryCode) { - const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode] - if (!patterns?.length || !text) { - return 0 - } - return patterns.some((pattern) => pattern.test(text)) - ? { - travel: 0.84, - hotel: 0.82, - transport: 0.8, - meal: 0.76, - meeting: 0.78, - entertainment: 0.88, - office: 0.74, - training: 0.77, - communication: 0.7, - welfare: 0.72 - }[categoryCode] || 0 - : 0 -} - -function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - const matchedScores = documents - .filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode) - .map((item) => Number(item?.avg_score || 0)) - .filter((score) => Number.isFinite(score) && score > 0) - - if (!matchedScores.length) { - return 0 - } - - return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length -} - -function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { - const normalizedLabel = String(selectedLabel || '').trim() - if (!normalizedLabel) { - return 0 - } - - const selectedCode = resolveExpenseTypeCode(normalizedLabel) - const slotMap = buildReviewSlotMap(reviewPayload) - const expenseSlot = slotMap.expense_type - const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '') - let score = 0 - - if (recognizedCode === selectedCode) { - score = Math.max(score, Number(expenseSlot?.confidence || 0)) - } - - score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode)) - score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode)) - - if (!score && normalizedLabel) { - score = selectedCode === 'other' ? 0.52 : 0.58 - } - - return Math.max(0, Math.min(0.98, Number(score.toFixed(2)))) -} - -function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { - const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) - return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({ - ...item, - active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel, - confidenceLabel: item.is_other - ? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState)) - : formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)), - caption: item.is_other - ? selectedLabel && !presetLabels.includes(selectedLabel) - ? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}` - : '点击选择更多类型' - : `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`, - groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多' - })) -} - -function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) { - return formatConfidenceLabel( - resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState) - ) -} - -function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { - if (slotKey === 'customer_name') { - return expenseTypeLabel === '业务招待费' - ? '业务招待费需补充客户单位名称,以便进行合规校验。' - : '当前仍缺少客户单位名称,建议补充后再提交。' - } - if (slotKey === 'participants') { - return '缺少同行人员信息,建议补充至少 1 名。' - } - if (slotKey === 'attachments') { - return '尚未上传票据附件,当前无法完成票据核对。' - } - if (slotKey === 'amount') { - return '报销金额仍待确认,提交前需补齐金额信息。' - } - if (slotKey === 'time_range') { - return '业务发生时间仍待确认,建议补充准确日期。' - } - if (slotKey === 'reason') { - return '报销事由说明仍不完整,建议补充业务背景。' - } - return '当前仍有识别信息待补充,建议先核对后再处理。' -} - -function buildReviewRiskSummary(reviewPayload) { - if (resolveReviewRiskBriefs(reviewPayload).length) { - return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。' - } - return '当前没有需要额外处理的结构化风险点。' -} - -function normalizeReviewRiskLevel(level) { - const normalized = String(level || '').trim().toLowerCase() - if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high' - if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium' - if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low' - if (normalized === 'high') return normalized - return 'low' -} - function normalizeReviewRiskTitle(title, fallbackTitle) { const normalized = String(title || '').trim() const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示' @@ -2982,437 +431,6 @@ function buildReviewRiskConversationText(item) { return lines.join('\n') } -function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) { - const state = inlineState || createEmptyInlineReviewState() - if (slotKey === 'expense_type') return String(state.expense_type || '').trim() - if (slotKey === 'customer_name') return String(state.customer_name || '').trim() - if (slotKey === 'time_range') return String(state.occurred_date || '').trim() - if (slotKey === 'location') return String(state.location || '').trim() - if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim() - if (slotKey === 'amount') return String(state.amount || '').trim() - if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim() - if (slotKey === 'participants') return String(state.participants || '').trim() - if (slotKey === 'attachments') { - return String(state.attachment_names || '').trim() - || (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '') - || (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '') - } - return '' -} - -function buildLocallySyncedReviewActions(reviewPayload, canProceed) { - const actions = Array.isArray(reviewPayload?.confirmation_actions) - ? reviewPayload.confirmation_actions.map((item) => ({ ...item })) - : [] - const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim())) - const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents') - - if (!canProceed || associationPending) { - return actions - } - - const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step') - if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) { - syncedActions.push({ - label: '保存为草稿', - action_type: 'save_draft', - description: '先暂存当前已识别信息,稍后仍可继续补充或提交。', - emphasis: 'secondary' - }) - } - - return [ - ...syncedActions, - { - label: '继续下一步', - action_type: 'next_step', - description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。', - emphasis: 'primary' - } - ] -} - -function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { - if (!reviewPayload || typeof reviewPayload !== 'object') { - return reviewPayload - } - - const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => { - const value = resolveInlineReviewSlotValue(slot.key, inlineState) - const required = Boolean(slot.required) - const filled = Boolean(value) - return { - ...slot, - value: value || slot.value || '', - normalized_value: value || slot.normalized_value || '', - raw_value: value || slot.raw_value || '', - source: filled ? 'user_form' : slot.source, - source_label: filled ? '用户修改' : slot.source_label, - confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0), - confirmed: filled || Boolean(slot.confirmed), - status: required && !filled ? 'missing' : filled ? 'identified' : slot.status, - hint: required && !filled ? slot.hint : '' - } - }) - const missingSlots = nextSlotCards - .filter((slot) => slot.required && slot.status === 'missing') - .map((slot) => slot.label || slot.key) - const extraMissingSlots = resolveReviewExtraMissingLabels({ - ...reviewPayload, - slot_cards: nextSlotCards - }) - const allMissingSlots = [...missingSlots, ...extraMissingSlots] - const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true) - - return { - ...reviewPayload, - can_proceed: canProceed, - missing_slots: allMissingSlots, - slot_cards: nextSlotCards, - confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed) - } -} - -function buildLocalReviewCompletionMessage(reviewPayload) { - const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : [] - if (reviewPayload?.can_proceed && !missingSlots.length) { - return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。' - } - if (missingSlots.length) { - return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}。` - } - return '当前信息已保存,可以继续核对右侧状态。' -} - -function normalizeInlineReviewComparableState(state) { - const source = state && typeof state === 'object' ? state : {} - return { - occurred_date: String(source.occurred_date || '').trim(), - amount: String(source.amount || '').trim(), - transport_type: String(source.transport_type || '').trim(), - scene_label: String(source.scene_label || '').trim(), - reason_value: String(source.reason_value || '').trim(), - customer_name: String(source.customer_name || '').trim(), - location: String(source.location || '').trim(), - merchant_name: String(source.merchant_name || '').trim(), - participants: String(source.participants || '').trim(), - attachment_names: String(source.attachment_names || '').trim(), - pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)), - expense_type: String(source.expense_type || '').trim() - } -} - -function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) { - const base = normalizeInlineReviewComparableState(baseState) - const next = normalizeInlineReviewComparableState(nextState) - const lines = [] - - if (base.occurred_date !== next.occurred_date) { - lines.push(`发生时间 ${next.occurred_date || '待补充'}`) - } - if (base.amount !== next.amount) { - lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`) - } - if (base.transport_type !== next.transport_type) { - lines.push(`交通类型 ${next.transport_type || '待确认'}`) - } - if (base.scene_label !== next.scene_label) { - lines.push(`场景 ${next.scene_label || '待补充'}`) - } - if (base.customer_name !== next.customer_name) { - lines.push(`关联客户 ${next.customer_name || '待补充'}`) - } - if (base.location !== next.location) { - lines.push(`业务地点 ${next.location || '待补充'}`) - } - if (base.merchant_name !== next.merchant_name) { - lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`) - } - if (base.participants !== next.participants) { - lines.push(`同行人员 ${next.participants || '待补充'}`) - } - if (base.expense_type !== next.expense_type) { - lines.push(`报销分类 ${next.expense_type || '待补充'}`) - } - if (base.attachment_names !== next.attachment_names || pendingFiles.length) { - lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) - } - - return lines -} - -function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) { - const base = normalizeInlineReviewComparableState(baseState) - const next = normalizeInlineReviewComparableState(nextState) - const fieldConfigs = [ - { key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' }, - { key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' }, - { key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' }, - { key: 'scene_label', label: '场景', format: (value) => value || '待补充' }, - { key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' }, - { key: 'location', label: '业务地点', format: (value) => value || '待补充' }, - { key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' }, - { key: 'participants', label: '同行人员', format: (value) => value || '待补充' }, - { key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' } - ] - - const phrases = fieldConfigs.reduce((result, item) => { - if (base[item.key] !== next[item.key]) { - result.push(`${item.label}修改为 ${item.format(next[item.key])}`) - } - return result - }, []) - - if (base.attachment_names !== next.attachment_names || pendingFiles.length) { - phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) - } - - return phrases -} - -function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { - const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles) - const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) - - if (documentLines.length) { - phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`) - } - - if (!phrases.length) { - return '右侧核对信息已保存。' - } - - return `已将${phrases.join(',')}。` -} - -function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { - const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) - if (!lines.length) { - return '我已修改识别信息,请按最新内容更新。' - } - return `我已修改识别信息:${lines.join(',')}。请按最新内容更新。` -} - -function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { - const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) - const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) - - if (!inlineLines.length && !documentLines.length) { - return '我已修改识别信息,请按最新内容更新。' - } - - const parts = [] - if (inlineLines.length) { - parts.push(inlineLines.join(',')) - } - if (documentLines.length) { - parts.push(`修正了 ${documentLines.length} 张票据识别信息`) - } - - return `我已修改识别信息:${parts.join(';')}。请按最新内容更新。` -} - -function mergeInlineReviewFields(baseFields, inlineState) { - const merged = cloneReviewEditFields(baseFields) - const updateMap = { - expense_type: inlineState.expense_type, - transport_type: inlineState.transport_type, - occurred_date: inlineState.occurred_date, - amount: inlineState.amount, - customer_name: inlineState.customer_name, - business_location: inlineState.location, - merchant_name: inlineState.merchant_name, - participants: inlineState.participants, - reason: inlineState.reason_value || inlineState.scene_label, - attachment_names: inlineState.attachment_names - } - - for (const item of merged) { - if (!(item.key in updateMap)) continue - item.value = String(updateMap[item.key] || '').trim() - } - - return merged -} - -function buildReviewRecognitionNotes(reviewPayload) { - const recognized = resolveReviewRecognizedSlotCards(reviewPayload) - const notes = [] - const timeSlot = recognized.find((item) => item.key === 'time_range') - const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] - - if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { - notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) - } - - if (sourceLabels.length) { - notes.push(`本轮主要依据:${sourceLabels.join('、')}`) - } - - const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - if (documentCards.length) { - notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) - } else { - notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别') - } - - return notes -} - -function buildReviewDocumentSummaries(reviewPayload) { - const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - return docs.map((item) => { - const fields = Array.isArray(item.fields) ? item.fields : [] - return { - ...item, - documentTypeLabel: resolveDocumentTypeLabel(item.document_type), - expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label), - confidenceLabel: formatConfidenceLabel(item.avg_score), - lines: fields - .filter((field) => String(field?.value || '').trim()) - .map((field) => `${field.label}:${field.value}`) - } - }) -} - -function buildReviewDecisionHint(reviewPayload) { - const pendingCount = countReviewPendingItems(reviewPayload) - const riskBriefs = resolveReviewRiskBriefs(reviewPayload) - if (reviewPayload?.can_proceed) { - if (shouldShowReviewUploadButton(reviewPayload)) { - return '必需信息已整理好;如还有非必需票据可以继续上传,也可以直接进入下一步或保存草稿。' - } - return riskBriefs.length - ? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。` - : '我已经把信息整理好了。你确认无误后,可以直接进入下一步。' - } - if (pendingCount) { - return `我先完成了当前这轮识别,还差 ${pendingCount} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。` - } - return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。' -} - -function buildReviewMissingHint(reviewPayload) { - if (!countReviewPendingItems(reviewPayload)) { - return '' - } - if (reviewPayload?.can_proceed) { - return '当前关键信息已经齐全,这里无需再补充。' - } - return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。' -} - -function buildReviewRiskHint(reviewPayload) { - const riskBriefs = resolveReviewRiskBriefs(reviewPayload) - if (!riskBriefs.length) { - return '' - } - return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。' -} - -function buildReviewActionHint(reviewPayload) { - if (reviewPayload?.can_proceed) { - return '如果识别无误,直接点“下一步”;如果有偏差,先修改识别信息。' - } - return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。' -} - -function shouldShowReviewUploadButton(reviewPayload) { - const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] - if (!documents.length) return true - if (countReviewPendingItems(reviewPayload)) return true - - return resolveReviewRiskBriefs(reviewPayload).some((brief) => { - const text = `${brief?.title || ''} ${brief?.content || ''} ${brief?.suggestion || ''}` - return /差旅票据待补充|待上传|可继续上传|可继续提供/.test(text) - }) -} - -function buildReviewStatusTag(reviewPayload) { - const missingCount = countReviewPendingItems(reviewPayload) - if (reviewPayload?.can_proceed) { - return '可继续处理' - } - if (missingCount > 0) { - return `待补充 ${missingCount} 项` - } - return '待确认' -} - -function buildErrorInsight(error, fileNames = []) { - return { - intent: 'agent', - metricLabel: '运行状态', - metricValue: '失败', - title: '智能体调用失败', - summary: error?.message || '无法连接后端 Orchestrator。', - agent: { - runId: '未生成', - selectedAgent: 'orchestrator', - scenario: '未知', - intent: '未知', - permissionLevel: 'unknown', - routeReason: 'request_failed', - requiresConfirmation: false, - degraded: false, - fileNames, - citations: [], - suggestedActions: [], - queryPayload: null, - draftPayload: null, - reviewPayload: null, - riskFlags: [], - toolCount: 0, - failedToolCount: 0, - selectedCapabilityCodes: [], - filePreviews: [], - statusLabel: '失败', - statusTone: 'note' - } - } -} - -function buildAgentInsight(payload, fileNames = [], filePreviews = []) { - const trace = payload?.trace_summary || {} - const result = payload?.result || {} - const statusLabel = resolveStatusLabel(payload?.status) - - return { - intent: 'agent', - metricLabel: '运行状态', - metricValue: statusLabel, - title: - result?.draft_payload?.title || - `${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`, - summary: result?.answer || result?.message || '智能体已完成处理。', - agent: { - runId: payload?.run_id || '未生成', - selectedAgent: payload?.selected_agent || 'orchestrator', - scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知', - intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知', - permissionLevel: payload?.permission_level || 'unknown', - routeReason: payload?.route_reason || 'unknown', - requiresConfirmation: Boolean(payload?.requires_confirmation), - degraded: Boolean(trace?.degraded), - fileNames, - citations: Array.isArray(result?.citations) ? result.citations : [], - suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], - queryPayload: normalizeExpenseQueryPayload(result?.query_payload), - draftPayload: result?.draft_payload || null, - reviewPayload: result?.review_payload || null, - riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], - toolCount: Number(trace?.tool_count || 0), - failedToolCount: Number(trace?.failed_tool_count || 0), - selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) - ? trace.selected_capability_codes - : [], - filePreviews, - statusLabel, - statusTone: resolveStatusTone(payload?.status) - } - } -} - export default { name: 'TravelReimbursementCreateView', components: { @@ -3448,170 +466,93 @@ export default { const fileInputRef = ref(null) const composerTextareaRef = ref(null) - const fileInputMode = ref('composer') const messageListRef = ref(null) const composerDraft = ref('') - const composerDatePickerOpen = ref(false) - const composerDateMode = ref('single') - const composerSingleDate = ref(formatDateInputValue()) - const composerRangeStartDate = ref(formatDateInputValue()) - const composerRangeEndDate = ref(formatDateInputValue()) - const composerBusinessTimeTags = ref([]) - const composerBusinessTimeDraftTouched = ref(false) - const travelCalculatorOpen = ref(false) - const travelCalculatorBusy = ref(false) - const travelCalculatorError = ref('') - const travelCalculatorResult = ref(null) - const travelCalculatorForm = ref({ - days: '1', - location: '' - }) - const attachedFiles = ref([]) - const composerFilesExpanded = ref(false) const submitting = ref(false) const workbenchVisible = ref(false) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) - const initialSessionType = resolveInitialSessionType(props.initialConversation) - const conversationInitialState = props.initialConversation - ? buildConversationSessionState(props.initialConversation, initialSessionType) - : buildEmptySessionState(initialSessionType) - const canRestorePersistedInitialState = - props.entrySource === 'workbench' - && !String(props.initialPrompt || '').trim() - && !props.initialFiles.length - const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType) - const persistedInitialState = canRestorePersistedInitialState - ? buildPersistedSessionState(persistedInitialSnapshot, initialSessionType) - : null - const initialSessionState = canRestorePersistedInitialState && shouldPreferPersistedSessionState( - persistedInitialState, - persistedInitialSnapshot, - props.initialConversation - ) - ? persistedInitialState - : conversationInitialState - const activeSessionType = ref(initialSessionState.sessionType) - const messages = ref(initialSessionState.messages) - const conversationId = ref(initialSessionState.conversationId) - const draftClaimId = ref(initialSessionState.draftClaimId) - const previewRegistry = [] - const restoredDraftPreviewClaims = new Set() - const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) - const sessionSnapshots = ref({ - [SESSION_TYPE_EXPENSE]: null, - [SESSION_TYPE_KNOWLEDGE]: null - }) - - const currentInsight = ref(initialSessionState.currentInsight) - const reviewCancelDialogOpen = ref(false) - const reviewEditDialogOpen = ref(false) + const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS + let sessionRuntimeRefs = {} const uploadDecisionDialogOpen = ref(false) + const { + activeSessionType, + messages, + conversationId, + draftClaimId, + sessionSnapshots, + currentInsight, + reviewFilePreviews, + composerUploadIntent, + insightPanelCollapsed, + sessionSwitchBusy, + buildEmptySessionState, + resolveCurrentUserId, + persistSessionState, + applySessionState, + clearKnowledgeSessionOnEntry, + switchSessionType + } = useTravelReimbursementSessionState({ + props, + currentUser, + linkedRequest, + toast, + composerDraft, + uploadDecisionDialogOpen, + adjustComposerTextareaHeight, + scrollToBottom, + getSessionRuntimeRefs: () => sessionRuntimeRefs + }) const deleteSessionDialogOpen = ref(false) const reviewActionBusy = ref(false) const deleteSessionBusy = ref(false) - const reviewEditFields = ref([]) - const reviewActionMessageId = ref('') - const reviewInlineForm = ref(createEmptyInlineReviewState()) - const reviewInlineBaseForm = ref(createEmptyInlineReviewState()) - const reviewInlineBaseFields = ref([]) - const reviewInlinePendingFiles = ref([]) - const reviewInlineEditorKey = ref('') - const reviewInlineErrors = ref({}) - const reviewOtherCategoryOpen = ref(false) - const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim()) - const reviewDocumentDrafts = ref([]) - const reviewDocumentBaseDrafts = ref([]) - const activeReviewDocumentIndex = ref(0) const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW) - const insightPanelCollapsed = ref(false) - const documentPreviewDialog = ref({ - open: false, - filename: '', - kind: 'file', - url: '' - }) - const sessionSwitchBusy = ref(false) - const flowRunId = ref('') - const flowStartedAt = ref(0) - const flowFinishedAt = ref(0) - const flowSteps = ref([]) - const flowRefreshBusy = ref(false) - const flowTick = ref(Date.now()) - let flowTickTimer = 0 - const flowSimulationTimers = [] - const canSubmit = computed( - () => - !submitting.value - && !sessionSwitchBusy.value - && Boolean( - composerDraft.value.trim() - || attachedFiles.value.length - || composerBusinessTimeTags.value.length - ) - ) - const composerCanApplyDateSelection = computed(() => { - if (composerDateMode.value === 'single') { - return Boolean(composerSingleDate.value) - } - return Boolean( - composerRangeStartDate.value - && composerRangeEndDate.value - && composerRangeStartDate.value <= composerRangeEndDate.value - ) - }) - const travelCalculatorCanSubmit = computed(() => - !travelCalculatorBusy.value - && Number(travelCalculatorForm.value.days) >= 1 - && Boolean(String(travelCalculatorForm.value.location || '').trim()) - ) const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) - const completedFlowStepCount = computed( - () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length - ) - const runningFlowStep = computed( - () => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null - ) - const flowOverallStatusTone = computed(() => { - if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { - return 'failed' - } - if (runningFlowStep.value) { - return 'running' - } - if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) { - return 'completed' - } - return 'pending' - }) - const flowOverallStatusText = computed(() => { - const total = flowSteps.value.length - const completed = completedFlowStepCount.value - if (flowOverallStatusTone.value === 'failed') { - return `异常 ${completed}/${total}` - } - if (flowOverallStatusTone.value === 'completed') { - return `已完成 ${total}/${total}` - } - if (flowOverallStatusTone.value === 'running') { - return `执行中 ${completed}/${total}` - } - return total ? `待执行 0/${total}` : '暂无流程' - }) - const flowTotalDurationText = computed(() => { - if (!flowStartedAt.value) { - return '--' - } - - const finishedAt = flowFinishedAt.value || (runningFlowStep.value ? flowTick.value : 0) - if (finishedAt > flowStartedAt.value) { - return formatFlowDuration(finishedAt - flowStartedAt.value) - } - - const measuredDuration = flowSteps.value.reduce((total, step) => { - const duration = Number(step.durationMs) - return total + (Number.isFinite(duration) && duration > 0 ? duration : 0) - }, 0) - return measuredDuration ? formatFlowDuration(measuredDuration) : '--' + const { + flowRunId, + flowSteps, + flowRefreshBusy, + completedFlowStepCount, + flowOverallStatusTone, + flowOverallStatusText, + flowTotalDurationText, + clearFlowSimulationTimers, + resetFlowRun, + startFlowTick, + stopFlowRuntime, + startFlowStep, + completeFlowStep, + failCurrentFlowStep, + startSemanticFlowPreview, + startExpenseSceneSelectionFlowPreview, + startExpenseIntentConfirmationFlowPreview, + startExpenseSceneSelectionAfterIntentConfirmation, + startReviewActionFlowStep, + startExpenseClaimDraftFlowStep, + completeFlowResult, + refreshFlowRunDetail, + formatFlowStepDuration, + resolveFlowStepStatusLabel, + resolveFlowStepDetail + } = useTravelReimbursementFlow({ + activeSessionType, + reviewDrawerMode, + insightPanelCollapsed, + isKnowledgeSession, + fetchAgentRunDetail, + buildLocalIntentPreview, + buildLocalExtractionProgressMessages, + summarizeSemanticIntentDetail, + summarizeSemanticParseDetail, + SCENARIO_LABELS, + INTENT_LABELS, + EXPENSE_TYPE_LABELS, + FLOW_STEP_FALLBACKS, + REVIEW_DRAWER_MODE_FLOW, + REVIEW_DRAWER_MODE_REVIEW, + FLOW_STEP_STATUS_PENDING, + FLOW_STEP_STATUS_RUNNING, + FLOW_STEP_STATUS_COMPLETED, + FLOW_STEP_STATUS_FAILED }) const hasInsightPanelContent = computed( () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || flowSteps.value.length > 0 @@ -3627,7 +568,7 @@ export default { if (props.entrySource === 'detail' && linkedRequest.value?.id) { return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` } - return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件生成报销草稿。' + return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件整理报销核对信息。' }) const currentIntentLabel = computed(() => { if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') { @@ -3654,107 +595,290 @@ export default { const activeReviewPayload = computed( () => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null ) - const activeReviewFilePreviews = computed(() => reviewFilePreviews.value) - const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS)) - const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS)) - const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) - const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) - const reviewCategoryOptions = computed(() => - buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value) - ) - const reviewOtherCategoryOptions = computed(() => - REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({ - ...item, - confidenceLabel: formatConfidenceLabel( - resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value) - ) - })) - ) - const reviewSelectedOtherCategory = computed(() => { - const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) - return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type + const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload) + const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver) + const { + reviewInlineForm, + reviewInlineBaseForm, + reviewInlineBaseFields, + reviewInlinePendingFiles, + reviewInlineEditorKey, + reviewInlineErrors, + reviewOtherCategoryOpen, + reviewDocumentDrafts, + reviewDocumentBaseDrafts, + activeReviewDocumentIndex, + documentPreviewDialog, + activeReviewFilePreviews, + reviewIntentText, + reviewFactCards, + reviewCategoryOptions, + reviewOtherCategoryOptions, + reviewSelectedOtherCategory, + reviewInlineDirty, + reviewPanelConfidence, + reviewRiskSummary, + reviewRiskItems, + reviewRiskEmpty, + reviewDocumentDrawerAvailable, + reviewRiskDrawerAvailable, + reviewFlowDrawerAvailable, + recognizedNarratives, + reviewRecognitionNotes, + reviewDocumentSummaries, + reviewDocumentCount, + isReviewDocumentDrawer, + isReviewRiskDrawer, + isReviewFlowDrawer, + reviewDrawerTitle, + reviewDocumentDrawerLabel, + reviewDocumentDrawerIcon, + reviewRiskDrawerLabel, + reviewRiskDrawerIcon, + reviewFlowDrawerLabel, + reviewFlowDrawerIcon, + activeReviewDocument, + activeReviewDocumentPreview, + canPreviewActiveReviewDocument, + reviewDocumentDirty, + reviewHasUnsavedChanges, + setInlineReviewFieldError, + clearInlineReviewFieldError, + resetReviewDrawerFromPayload, + enforceReviewDrawerAvailability, + openInlineReviewEditor, + closeInlineReviewEditor, + commitInlineReviewEditor, + selectInlineScene, + selectReviewCategory, + selectReviewOtherCategory, + goReviewDocument, + openActiveReviewDocumentPreview, + closeDocumentPreview + } = useTravelReimbursementReviewDrawer({ + activeReviewPayload, + reviewFilePreviews, + flowSteps, + submitting, + reviewActionBusy, + triggerFileUpload: (...args) => triggerFileUpload(...args), + resolveDocumentPreview, + buildReviewFactCards, + buildReviewRiskItems, + buildReviewRiskSummary, + buildReviewIntentText, + resolveReviewRiskBriefs, + reviewDrawerMode, + REVIEW_DRAWER_MODE_REVIEW, + REVIEW_DRAWER_MODE_DOCUMENTS, + REVIEW_DRAWER_MODE_RISK, + REVIEW_DRAWER_MODE_FLOW }) - const reviewInlineDirty = computed( + const { + composerDatePickerOpen, + composerDateMode, + composerSingleDate, + composerRangeStartDate, + composerRangeEndDate, + composerBusinessTimeTags, + composerBusinessTimeDraftTouched, + composerCanApplyDateSelection, + travelCalculatorOpen, + travelCalculatorBusy, + travelCalculatorError, + travelCalculatorResult, + travelCalculatorForm, + travelCalculatorCanSubmit, + buildComposerBusinessTimeLabel, + hasComposerBusinessTimeSelection, + buildComposerBusinessTimeContext, + mergeBusinessTimeIntoExtraContext, + syncComposerBusinessTimeToReviewCard, + resolveComposerSubmitText, + toggleComposerDatePicker, + closeComposerDatePicker, + setComposerDateMode, + handleComposerDateInputChange, + removeComposerBusinessTimeTag, + handleComposerDatePickerOutside, + applyComposerDateSelection, + resolveTravelCalculatorInitialDays, + resolveTravelCalculatorInitialLocation, + openTravelCalculator, + toggleTravelCalculator: toggleTravelCalculatorInternal, + closeTravelCalculator, + formatTravelCalculatorMoney, + buildTravelCalculatorResultText, + submitTravelCalculator: submitTravelCalculatorInternal + } = useTravelReimbursementComposerTools({ + currentUser, + activeReviewPayload, + reviewInlineForm, + latestReviewMessage, + currentInsight, + messages, + composerDraft, + composerTextareaRef, + adjustComposerTextareaHeight, + scrollToBottom, + toast, + calculateTravelReimbursement, + createMessage, + buildReviewSlotMap, + isValidIsoDateString, + buildLocallySyncedReviewPayload, + formatDateInputValue + }) + const { + fileInputMode, + attachedFiles, + composerFilesExpanded, + visibleAttachedFiles, + hiddenAttachedFileCount, + rememberFilePreviews, + buildComposerFilePreviews, + resolveActiveClaimId, + restorePersistedDraftAttachmentPreviews, + syncComposerFilesToDraft, + triggerFileUpload, + handleFilesChange, + toggleAttachedFilesExpanded, + removeAttachedFile, + clearAttachedFiles, + stopAttachmentRuntime + } = useTravelReimbursementAttachments({ + isKnowledgeSession, + reviewFilePreviews, + linkedRequest, + draftClaimId, + activeReviewPayload, + reviewInlinePendingFiles, + reviewInlineForm, + reviewInlineEditorKey, + composerUploadIntent, + submitting, + reviewActionBusy, + toast, + fileInputRef, + fetchExpenseClaimDetail, + fetchExpenseClaimItemAttachmentMeta, + fetchExpenseClaimAttachmentAsset, + uploadExpenseClaimItemAttachment, + extractReviewAttachmentNames, + mergeFilesWithLimit, + mergeFilePreviews, + resolveAttachmentPreviewKind, + resolveDocumentPreview, + buildFilePreviews, + buildFileIdentity, + MAX_ATTACHMENTS, + VISIBLE_ATTACHMENT_CHIPS, + clearInlineReviewFieldError + }) + sessionRuntimeRefs = { + attachedFiles, + composerFilesExpanded + } + const { submitComposerInternal } = useTravelReimbursementSubmitComposer({ + MAX_ATTACHMENTS, + activeReviewPayload, + activeSessionType, + adjustComposerTextareaHeight, + attachedFiles, + buildAgentInsight, + buildClientTimeContext, + buildComposerBusinessTimeContext, + buildComposerFilePreviews, + buildDraftAssociationQueryPayload, + buildErrorInsight, + buildExpenseIntentConfirmationActions, + buildExpenseIntentConfirmationMessage, + buildExpenseSceneSelectionActions, + buildExpenseSceneSelectionMessage, + buildMessageMeta, + buildOcrDocumentsFromReviewPayload, + buildOcrFilePreviews, + buildOcrSummary, + buildOcrSummaryFromDocuments, + buildReviewFormContextFromPayload, + clearAttachedFiles, + clearFlowSimulationTimers, + completeFlowResult, + completeFlowStep, + composerBusinessTimeDraftTouched, + composerBusinessTimeTags, + composerDraft, + composerUploadIntent, + conversationId, + createMessage, + currentInsight, + currentUser, + draftClaimId, + extractReviewAttachmentNames, + failCurrentFlowStep, + fetchExpenseClaims, + fileInputRef, + flowRunId, + isKnowledgeSession, + linkedRequest, + mergeBusinessTimeIntoExtraContext, + mergeFilePreviews, + mergeFilesWithLimit, + mergeUploadAttachmentNames, + mergeUploadOcrDocuments, + messages, + nextTick, + normalizeExpenseQueryPayload, + normalizeOcrDocuments, + persistSessionState, + props, + recognizeOcrFiles, + refreshFlowRunDetail, + rememberFilePreviews, + replaceMessage, + resetFlowRun, + resolveComposerSubmitText, + reviewInlineForm, + runOrchestrator, + scrollToBottom, + sessionSwitchBusy, + shouldRequestExpenseIntentConfirmation, + shouldRequestExpenseSceneSelection, + startExpenseClaimDraftFlowStep, + startExpenseIntentConfirmationFlowPreview, + startExpenseSceneSelectionFlowPreview, + startFlowStep, + startSemanticFlowPreview, + submitting, + syncComposerFilesToDraft, + uploadDecisionDialogOpen, + toast + }) + const canSubmit = computed( () => - buildInlineReviewChangedLines( - reviewInlineBaseForm.value, - reviewInlineForm.value, - reviewInlinePendingFiles.value - ).length > 0 + !submitting.value + && !sessionSwitchBusy.value + && Boolean( + composerDraft.value.trim() + || attachedFiles.value.length + || composerBusinessTimeTags.value.length + ) ) - const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) - const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) - const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) - const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length) - const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0) - const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value) - const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0) - const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value)) - const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value)) - const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value)) - const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length) + function toggleTravelCalculator() { + return toggleTravelCalculatorInternal() + } + + function submitTravelCalculator() { + // 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。 + // calculateTravelReimbursement({ grade: String(user.grade || '').trim() }) + // 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计 + // 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元 + // 鏍规嵁鎮ㄨ緭鍏ョ殑鍦扮偣鍜屽ぉ鏁帮紝鍖归厤鍒版偍瑕佸嚭宸殑鍦板尯涓猴紝鍙傝€冨彲鎶ラ攢鍚堣 + // 浣忓璐癸細${hotelRate} 脳 ${days} = ${hotelAmount} 鍏 + // messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload) + return submitTravelCalculatorInternal() + } const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW) - const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) - const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) - const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) - const reviewDrawerTitle = computed(() => ( - isReviewDocumentDrawer.value - ? '票据识别结果' - : isReviewRiskDrawer.value - ? '风险提示' - : isReviewFlowDrawer.value - ? '调用流程' - : '报销识别核对' - )) - const reviewDocumentDrawerLabel = computed(() => ( - '单据识别' - )) - const reviewDocumentDrawerIcon = computed(() => ( - isReviewDocumentDrawer.value - ? 'mdi mdi-file-document-multiple' - : 'mdi mdi-file-document-multiple-outline' - )) - const reviewRiskDrawerLabel = computed(() => ( - '显示风险' - )) - const reviewRiskDrawerIcon = computed(() => ( - isReviewRiskDrawer.value - ? 'mdi mdi-shield-alert' - : 'mdi mdi-shield-alert-outline' - )) - const reviewFlowDrawerLabel = computed(() => ( - '调用流程' - )) - const reviewFlowDrawerIcon = computed(() => ( - isReviewFlowDrawer.value - ? 'mdi mdi-timeline-clock' - : 'mdi mdi-timeline-clock-outline' - )) - const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null) - const activeReviewDocumentPreview = computed(() => - activeReviewDocument.value - ? ( - resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename) - || ( - activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url - ? { - filename: activeReviewDocument.value.filename, - kind: activeReviewDocument.value.preview_kind, - url: activeReviewDocument.value.preview_data_url - } - : null - ) - ) - : null - ) - const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url)) - const reviewDocumentDirty = computed(() => { - const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue)) - const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue)) - return baseValue !== nextValue - }) - const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value) - const hotKnowledgeQuestions = computed(() => HOT_KNOWLEDGE_QUESTIONS) const shortcuts = computed(() => [ { @@ -3764,260 +888,14 @@ export default { targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE } ]) - - function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) { - const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType - const restoredMessages = normalizeInitialConversationMessages(conversation) - const initialInsight = buildInitialInsightFromConversation(conversation) - const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages) - - return { - sessionType, - messages: restoredMessages.length - ? restoredMessages - : [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)], - conversationId: resolveInitialConversationId(conversation), - draftClaimId: resolveInitialDraftClaimId(conversation), - currentInsight: - initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), - reviewFilePreviews: restoredReviewFilePreviews, - composerDraft: '', - attachedFiles: [], - composerFilesExpanded: false, - composerUploadIntent: '', - insightPanelCollapsed: false - } - } - - function buildEmptySessionState(sessionType) { - return { - sessionType, - messages: [ - createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value) - ], - conversationId: '', - draftClaimId: '', - currentInsight: buildWelcomeInsight( - props.entrySource, - linkedRequest.value, - sessionType, - currentUser.value - ), - reviewFilePreviews: [], - composerDraft: '', - attachedFiles: [], - composerFilesExpanded: false, - composerUploadIntent: '', - insightPanelCollapsed: false - } - } - - function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) { - const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null - if (!state) { - return null - } - - const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE - const restoredMessages = normalizeSnapshotMessages(state.messages) - if ( - !hasMeaningfulSessionMessages(restoredMessages) - && !String(state.conversationId || '').trim() - && !String(state.draftClaimId || '').trim() - ) { - return null - } - - return { - sessionType, - messages: restoredMessages.length - ? restoredMessages - : [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)], - conversationId: String(state.conversationId || '').trim(), - draftClaimId: String(state.draftClaimId || '').trim(), - currentInsight: - state.currentInsight - || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), - reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [], - composerDraft: String(state.composerDraft || ''), - attachedFiles: [], - composerFilesExpanded: false, - composerUploadIntent: String(state.composerUploadIntent || '').trim(), - insightPanelCollapsed: Boolean(state.insightPanelCollapsed) - } - } - - function resolveCurrentUserId() { - const user = currentUser.value || {} - return String(user.username || user.name || 'anonymous').trim() || 'anonymous' - } - - function buildPersistableSessionState(sessionState) { - const state = sessionState || captureCurrentSessionState() - return { - sessionType: state.sessionType || SESSION_TYPE_EXPENSE, - messages: serializeSessionMessages(state.messages), - conversationId: String(state.conversationId || '').trim(), - draftClaimId: String(state.draftClaimId || '').trim(), - currentInsight: state.currentInsight || null, - reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [], - composerDraft: String(state.composerDraft || ''), - composerUploadIntent: String(state.composerUploadIntent || '').trim(), - insightPanelCollapsed: Boolean(state.insightPanelCollapsed) - } - } - - function persistSessionState(sessionState = null) { - const state = sessionState || captureCurrentSessionState() - const persistedState = buildPersistableSessionState(state) - const meaningful = Boolean( - String(persistedState.conversationId || '').trim() - || String(persistedState.draftClaimId || '').trim() - || hasMeaningfulSessionMessages(persistedState.messages) - || String(persistedState.composerDraft || '').trim() - ) - - if (!meaningful) { - clearAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType) - return - } - - writeAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType, persistedState) - } - - function captureCurrentSessionState() { - return { - sessionType: activeSessionType.value, - messages: messages.value, - conversationId: conversationId.value, - draftClaimId: draftClaimId.value, - currentInsight: currentInsight.value, - reviewFilePreviews: reviewFilePreviews.value, - composerDraft: composerDraft.value, - attachedFiles: attachedFiles.value, - composerFilesExpanded: composerFilesExpanded.value, - composerUploadIntent: composerUploadIntent.value, - insightPanelCollapsed: insightPanelCollapsed.value - } - } - - function applySessionState(sessionState) { - const nextState = sessionState || buildEmptySessionState(activeSessionType.value) - activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE - messages.value = Array.isArray(nextState.messages) && nextState.messages.length - ? nextState.messages - : [ - createWelcomeAssistantMessage( - props.entrySource, - linkedRequest.value, - activeSessionType.value, - currentUser.value - ) - ] - conversationId.value = String(nextState.conversationId || '').trim() - draftClaimId.value = String(nextState.draftClaimId || '').trim() - currentInsight.value = - nextState.currentInsight - || buildWelcomeInsight( - props.entrySource, - linkedRequest.value, - activeSessionType.value, - currentUser.value - ) - reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : [] - composerDraft.value = String(nextState.composerDraft || '') - attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : [] - composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded) - composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim() - insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed) - uploadDecisionDialogOpen.value = false - nextTick(() => { - adjustComposerTextareaHeight() - scrollToBottom() - }) - } - - async function loadLatestSessionState(targetSessionType) { - const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, { - preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE - }) - if (payload?.found && payload.conversation) { - return buildConversationSessionState(payload.conversation, targetSessionType) - } - return buildEmptySessionState(targetSessionType) - } - - function resetKnowledgeSessionSnapshot() { - const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE) - sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState - - if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) { - applySessionState(emptyKnowledgeState) - } - } - - function clearKnowledgeSessionOnEntry() { - resetKnowledgeSessionSnapshot() - knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE) - .catch((error) => { - console.warn('Failed to clear knowledge session on entry:', error) - }) - .finally(() => { - resetKnowledgeSessionSnapshot() - }) - return knowledgeSessionResetPromise - } - - async function switchSessionType(targetSessionType) { - const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE - if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) { - return - } - - sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState() - if (sessionSnapshots.value[normalizedTarget]) { - applySessionState(sessionSnapshots.value[normalizedTarget]) - return - } - - sessionSwitchBusy.value = true - try { - const nextState = await loadLatestSessionState(normalizedTarget) - sessionSnapshots.value[normalizedTarget] = nextState - applySessionState(nextState) - } catch (error) { - const emptyState = buildEmptySessionState(normalizedTarget) - sessionSnapshots.value[normalizedTarget] = emptyState - applySessionState(emptyState) - toast(error?.message || '加载会话失败,已为你打开新的会话。') - } finally { - sessionSwitchBusy.value = false - } - } - - sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState() - watch( () => activeReviewPayload.value, (payload) => { rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload)) - const nextInlineState = buildInlineReviewState(payload) - reviewInlineForm.value = { ...nextInlineState } - reviewInlineBaseForm.value = { ...nextInlineState } - reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) - const nextDocumentDrafts = buildReviewDocumentDrafts(payload) - reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) - reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) - activeReviewDocumentIndex.value = nextDocumentDrafts.length - ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) - : 0 - reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length - ? REVIEW_DRAWER_MODE_RISK - : REVIEW_DRAWER_MODE_REVIEW - reviewInlinePendingFiles.value = [] - reviewInlineEditorKey.value = '' - reviewInlineErrors.value = {} - reviewOtherCategoryOpen.value = false + // reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length + // ? REVIEW_DRAWER_MODE_RISK + // : REVIEW_DRAWER_MODE_REVIEW + resetReviewDrawerFromPayload(payload) }, { immediate: true } ) @@ -4096,9 +974,7 @@ export default { onMounted(() => { document.addEventListener('click', handleComposerDatePickerOutside) - flowTickTimer = window.setInterval(() => { - flowTick.value = Date.now() - }, 250) + startFlowTick() nextTick(() => { workbenchVisible.value = true }) @@ -4125,13 +1001,8 @@ export default { onBeforeUnmount(() => { document.removeEventListener('click', handleComposerDatePickerOutside) - if (flowTickTimer) { - window.clearInterval(flowTickTimer) - } - clearFlowSimulationTimers() - for (const url of previewRegistry) { - URL.revokeObjectURL(url) - } + stopFlowRuntime() + stopAttachmentRuntime() }) function scrollToBottom() { @@ -4143,11 +1014,7 @@ export default { const emptyState = buildEmptySessionState(activeSessionType.value) sessionSnapshots.value[activeSessionType.value] = emptyState applySessionState(emptyState) - clearFlowSimulationTimers() - flowRunId.value = '' - flowStartedAt.value = 0 - flowFinishedAt.value = 0 - flowSteps.value = [] + resetFlowRun({ startedAt: 0, openDrawer: false }) } function adjustComposerTextareaHeight() { @@ -4178,917 +1045,6 @@ export default { submitComposer() } - function clearFlowSimulationTimers() { - while (flowSimulationTimers.length) { - const timerId = flowSimulationTimers.pop() - window.clearTimeout(timerId) - window.clearInterval(timerId) - } - } - - function resetFlowRun() { - clearFlowSimulationTimers() - flowRunId.value = '' - flowStartedAt.value = Date.now() - flowFinishedAt.value = 0 - reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW - insightPanelCollapsed.value = false - flowSteps.value = [] - } - - function findFlowDefinition(key) { - return FLOW_STEP_FALLBACKS[key] || null - } - - function normalizeFlowStepPatch(key, patch = {}) { - const definition = findFlowDefinition(key) || {} - const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch } - return { - title: normalizedPatch.title || definition.title || '智能体工具调用', - tool: normalizedPatch.tool || definition.tool || 'AgentTool', - detail: normalizedPatch.detail || definition.runningText || '', - ...normalizedPatch - } - } - - function createFlowStep(key, patch = {}) { - const normalizedPatch = normalizeFlowStepPatch(key, patch) - return { - key, - index: flowSteps.value.length + 1, - title: normalizedPatch.title, - tool: normalizedPatch.tool, - status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING, - detail: normalizedPatch.detail || '', - durationMs: normalizedPatch.durationMs ?? null, - startedAt: normalizedPatch.startedAt || 0, - finishedAt: normalizedPatch.finishedAt || 0, - error: normalizedPatch.error || '' - } - } - - function normalizeFlowStepIndexes(steps) { - return steps.map((step, index) => ({ ...step, index: index + 1 })) - } - - function upsertFlowStep(key, patch) { - const existingStep = flowSteps.value.find((step) => step.key === key) - if (!existingStep) { - const nextStep = createFlowStep(key, patch) - flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep]) - return - } - const normalizedPatch = normalizeFlowStepPatch(key, patch) - flowSteps.value = flowSteps.value.map((step) => ( - step.key === key ? { ...step, ...normalizedPatch } : step - )) - } - - function startFlowStep(key, patch = {}) { - const normalizedPatch = normalizeFlowStepPatch(key, patch) - const explicitStartedAt = Number(normalizedPatch.startedAt) - const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0 - ? explicitStartedAt - : Date.now() - upsertFlowStep(key, { - ...normalizedPatch, - status: FLOW_STEP_STATUS_RUNNING, - detail: normalizedPatch.detail, - startedAt, - finishedAt: 0, - durationMs: null, - error: '' - }) - } - - function completeFlowStep(key, detail = '', durationMs = null, patch = {}) { - const now = Date.now() - const definition = findFlowDefinition(key) - const currentStep = flowSteps.value.find((step) => step.key === key) - const explicitDuration = Number(durationMs) - const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0 - const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now) - upsertFlowStep(key, { - ...patch, - status: FLOW_STEP_STATUS_COMPLETED, - detail: detail || definition?.completedText || '', - startedAt, - finishedAt: now, - durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt), - error: '' - }) - } - - function failFlowStep(key, detail = '', error = '', patch = {}) { - const now = Date.now() - const definition = findFlowDefinition(key) - const currentStep = flowSteps.value.find((step) => step.key === key) - const startedAt = currentStep?.startedAt || now - upsertFlowStep(key, { - ...patch, - status: FLOW_STEP_STATUS_FAILED, - detail: detail || error || '调用失败', - startedAt, - finishedAt: now, - durationMs: now - startedAt, - error: String(error || definition?.title || '').trim() - }) - flowFinishedAt.value = now - } - - function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) { - const currentStep = flowSteps.value.find((step) => step.key === key) - if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) { - return - } - const normalizedDuration = Number(durationMs) - const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0 - if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) { - if (!hasMeasuredDuration && !currentStep?.startedAt) { - upsertFlowStep(key, { - ...patch, - status: FLOW_STEP_STATUS_COMPLETED, - detail: detail || findFlowDefinition(key)?.completedText || '', - startedAt: 0, - finishedAt: 0, - durationMs: null, - error: '' - }) - return - } - startFlowStep(key, patch) - } - completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch) - } - - function failCurrentFlowStep(error) { - clearFlowSimulationTimers() - const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING) - failFlowStep( - currentStep?.key || 'orchestrator-error', - error?.message || '智能体调用失败', - error?.message || '', - currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' } - ) - } - - function startSemanticFlowPreview(rawText, options = {}) { - clearFlowSimulationTimers() - const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS }) - const extractionMessages = buildLocalExtractionProgressMessages(rawText, options) - - const completeIntentTimer = window.setTimeout(() => { - const currentStep = flowSteps.value.find((step) => step.key === 'intent') - if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { - return - } - completePendingFlowStep('intent', intentPreview, null) - }, 260) - flowSimulationTimers.push(completeIntentTimer) - - const startExtractionTimer = window.setTimeout(() => { - const currentStep = flowSteps.value.find((step) => step.key === 'extraction') - if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { - return - } - startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText) - - if (extractionMessages.length <= 1) { - return - } - - let index = 1 - const detailTimer = window.setInterval(() => { - const runningStep = flowSteps.value.find((step) => step.key === 'extraction') - if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) { - window.clearInterval(detailTimer) - return - } - upsertFlowStep('extraction', { - detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1] - }) - index = Math.min(index + 1, extractionMessages.length - 1) - }, 650) - flowSimulationTimers.push(detailTimer) - }, 420) - flowSimulationTimers.push(startExtractionTimer) - } - - function startExpenseSceneSelectionFlowPreview(rawText) { - clearFlowSimulationTimers() - const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS }) - const completeIntentTimer = window.setTimeout(() => { - completePendingFlowStep('intent', intentPreview, null) - }, 220) - flowSimulationTimers.push(completeIntentTimer) - - const startSelectionTimer = window.setTimeout(() => { - startFlowStep('expense-scene-selection', { - detail: '报销意图已确认,但费用场景还不明确;暂停抽取和草稿生成,等待用户先选择报销场景。' - }) - }, 320) - flowSimulationTimers.push(startSelectionTimer) - } - - function startExpenseIntentConfirmationFlowPreview(rawText) { - clearFlowSimulationTimers() - const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS }) - const completeIntentTimer = window.setTimeout(() => { - completePendingFlowStep('intent', intentPreview, null) - }, 220) - flowSimulationTimers.push(completeIntentTimer) - - const startConfirmationTimer = window.setTimeout(() => { - startFlowStep('expense-intent-confirmation', { - detail: '识别到业务事项描述,但是否发起报销还不明确;暂停抽取和草稿生成,等待用户确认。' - }) - }, 320) - flowSimulationTimers.push(startConfirmationTimer) - } - - function startExpenseSceneSelectionAfterIntentConfirmation(rawText) { - clearFlowSimulationTimers() - completePendingFlowStep('expense-intent-confirmation', '用户已确认要发起报销', null) - startFlowStep('expense-scene-selection', { - detail: '报销意图已确认,但费用场景还不明确;暂停抽取和草稿生成,等待用户先选择报销场景。' - }) - if (reviewDrawerMode.value !== REVIEW_DRAWER_MODE_FLOW) { - reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW - } - } - - function isExpenseSceneSelectionResult(payload) { - const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} - if (result.review_payload) { - return false - } - return (Array.isArray(result.suggested_actions) ? result.suggested_actions : []).some( - (item) => String(item?.action_type || '').trim() === 'select_expense_type' - ) - } - - function startReviewActionFlowStep(reviewAction) { - if (reviewAction !== 'next_step') { - return - } - - startFlowStep('pre-submit-review', { - title: 'AI预审与风险识别', - tool: 'ExpenseClaimService.submit_claim', - detail: '正在校验财务规则、风险规则和审批路径...' - }) - } - - function startExpenseClaimDraftFlowStep(reviewAction, options = {}) { - if (isKnowledgeSession.value) { - return - } - if (options.waitForSceneSelection) { - return - } - if (reviewAction === 'next_step') { - startReviewActionFlowStep(reviewAction) - return - } - - const attachmentCount = Math.max(0, Number(options.attachmentCount || 0)) - const configs = { - save_draft: { - title: '报销草稿保存', - detail: '正在保存当前核对结果...' - }, - link_to_existing_draft: { - title: '票据关联草稿', - detail: '正在把本次票据关联到现有草稿...' - }, - create_new_claim_from_documents: { - title: '新建报销草稿', - detail: '正在根据当前票据新建报销草稿...' - } - } - const config = configs[reviewAction] || { - title: '报销草稿处理', - detail: attachmentCount - ? '正在根据 OCR 结果更新草稿和右侧核对信息...' - : '正在更新草稿和右侧核对信息...' - } - - startFlowStep('expense-claim-draft', { - title: config.title, - tool: 'database.expense_claims.save_or_submit', - detail: config.detail - }) - } - - function resolveToolCallFlowMeta(toolCall, index) { - const toolType = String(toolCall?.tool_type || '').toLowerCase() - const toolName = String(toolCall?.tool_name || '').toLowerCase() - const response = toolCall?.response_json && typeof toolCall.response_json === 'object' - ? toolCall.response_json - : {} - const responseMessage = String(response.message || '').trim() - const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}` - if (toolType.includes('rule')) { - return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' } - } - if (toolType.includes('mcp')) { - return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' } - } - if (toolName.includes('knowledge')) { - return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' } - } - if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) { - if ( - response.submission_blocked || - String(response.status || '').trim() === 'submitted' || - responseMessage.includes('AI预审') || - responseMessage.includes('审批') - ) { - return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' } - } - return { key: 'expense-claim-draft', title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' } - } - if (toolType.includes('database')) { - return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' } - } - if (toolType.includes('llm') || toolName.includes('user_agent')) { - return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' } - } - return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' } - } - - function summarizeFlowToolCall(toolCall) { - const response = toolCall?.response_json && typeof toolCall.response_json === 'object' - ? toolCall.response_json - : {} - if (String(response.status || '').trim() === 'submitted') { - return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}` - } - if (response.submission_blocked) { - return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批' - } - return ( - String(response.message || response.summary || response.result_summary || '').trim() - || String(toolCall?.tool_name || '').trim() - || '工具调用完成' - ) - } - - function mergeFlowRunDetail(run) { - const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] - if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) { - clearFlowSimulationTimers() - const semanticDurations = resolveSemanticPhaseDurations(run) - const intentStep = flowSteps.value.find((step) => step.key === 'intent') - const extractionStep = flowSteps.value.find((step) => step.key === 'extraction') - completePendingFlowStep( - 'intent', - summarizeSemanticIntentDetail(run.semantic_parse, { - scenarioLabels: SCENARIO_LABELS, - intentLabels: INTENT_LABELS, - expenseTypeLabels: EXPENSE_TYPE_LABELS, - fallbackText: FLOW_STEP_FALLBACKS.intent.completedText - }), - intentStep?.startedAt ? null : semanticDurations.intentMs - ) - completePendingFlowStep( - 'extraction', - summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}), - extractionStep?.startedAt ? null : semanticDurations.extractionMs - ) - } - - toolCalls.forEach((toolCall, index) => { - const meta = resolveToolCallFlowMeta(toolCall, index) - const failed = String(toolCall?.status || '').toLowerCase() === 'failed' - if (failed) { - failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta) - } else { - const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run) - completePendingFlowStep( - meta.key, - summarizeFlowToolCall(toolCall), - toolDurationMs, - meta - ) - } - }) - - if (String(run?.status || '').toLowerCase() === 'failed') { - failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' }) - return - } - } - - function completeFlowResult(payload, run = null) { - const answer = String(payload?.result?.answer || payload?.result?.message || '').trim() - if (!answer && !payload?.result) { - return - } - const sceneSelectionPending = isExpenseSceneSelectionResult(payload) - flowSteps.value - .filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) - .forEach((step) => { - const detail = sceneSelectionPending && step.key === 'expense-scene-selection' - ? '已暂停后续识别,请先在主对话中选择报销场景。' - : resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED }) - completeFlowStep(step.key, detail) - }) - flowFinishedAt.value = Date.now() - if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && !sceneSelectionPending) { - reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW - } - } - - async function refreshFlowRunDetail() { - if (!flowRunId.value || flowRefreshBusy.value) { - return null - } - flowRefreshBusy.value = true - try { - const run = await fetchAgentRunDetail(flowRunId.value) - mergeFlowRunDetail(run) - return run - } catch (error) { - console.warn('Failed to refresh agent run detail:', error) - return null - } finally { - flowRefreshBusy.value = false - } - } - - function formatFlowStepDuration(step) { - if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) { - return formatFlowDuration(flowTick.value - step.startedAt) - } - return formatFlowDuration(step?.durationMs) - } - - function resolveFlowStepStatusLabel(step) { - const status = String(step?.status || '').trim() - if (status === FLOW_STEP_STATUS_COMPLETED) { - return '完成' - } - if (status === FLOW_STEP_STATUS_RUNNING) { - return '执行中' - } - if (status === FLOW_STEP_STATUS_FAILED) { - return '异常' - } - return '待执行' - } - - function resolveFlowStepDetail(step) { - const detail = String(step?.detail || '').trim() - if (detail) { - return detail - } - const definition = findFlowDefinition(step?.key) - if (step?.status === FLOW_STEP_STATUS_COMPLETED) { - return definition?.completedText || '步骤已完成' - } - if (step?.status === FLOW_STEP_STATUS_RUNNING) { - return definition?.runningText || '正在执行当前步骤...' - } - if (step?.status === FLOW_STEP_STATUS_FAILED) { - return step?.error || '步骤执行异常' - } - return definition?.runningText ? `等待${definition.title || '当前步骤'}...` : '等待智能体调度...' - } - - function buildComposerBusinessTimeLabel() { - if (composerDateMode.value === 'single') { - return `业务发生时间:${composerSingleDate.value}` - } - if (composerRangeStartDate.value === composerRangeEndDate.value) { - return `业务发生时间:${composerRangeStartDate.value}` - } - return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}` - } - - function hasComposerBusinessTimeSelection() { - return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value - } - - function buildComposerBusinessTimeContext() { - if (!hasComposerBusinessTimeSelection()) { - return null - } - - const mode = composerDateMode.value === 'range' ? 'range' : 'single' - const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim() - const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim() - if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { - return null - } - - const displayValue = mode === 'range' && startDate !== endDate - ? `${startDate} 至 ${endDate}` - : startDate - return { - mode, - start_date: startDate, - end_date: endDate, - occurred_date: startDate, - time_range: displayValue, - business_time: displayValue, - time_range_raw: buildComposerBusinessTimeLabel() - } - } - - function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) { - if (!businessTimeContext) { - return extraContext - } - - const baseReviewFormValues = - extraContext.review_form_values && typeof extraContext.review_form_values === 'object' - ? extraContext.review_form_values - : {} - - return { - ...extraContext, - occurred_date: businessTimeContext.occurred_date, - business_time: businessTimeContext.business_time, - business_time_context: { - mode: businessTimeContext.mode, - start_date: businessTimeContext.start_date, - end_date: businessTimeContext.end_date, - display_value: businessTimeContext.business_time - }, - review_form_values: { - ...baseReviewFormValues, - occurred_date: businessTimeContext.occurred_date, - time_range: businessTimeContext.time_range, - business_time: businessTimeContext.business_time, - time_range_raw: businessTimeContext.time_range_raw - } - } - } - - function syncComposerBusinessTimeToReviewCard(businessTimeContext) { - if (!businessTimeContext || !activeReviewPayload.value) { - return - } - - const nextInlineState = { - ...reviewInlineForm.value, - occurred_date: businessTimeContext.occurred_date - } - const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState) - reviewInlineForm.value = nextInlineState - if (latestReviewMessage.value) { - latestReviewMessage.value.reviewPayload = nextReviewPayload - } - if (currentInsight.value?.agent) { - currentInsight.value = { - ...currentInsight.value, - agent: { - ...currentInsight.value.agent, - reviewPayload: nextReviewPayload - } - } - } - } - - function resolveComposerSubmitText(explicitRawText) { - const draftPart = String(explicitRawText ?? composerDraft.value).trim() - const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',') - if (!tagPart) { - return draftPart - } - if (!draftPart) { - return tagPart - } - return `${tagPart},${draftPart}` - } - - function toggleComposerDatePicker() { - composerDatePickerOpen.value = !composerDatePickerOpen.value - if (composerDatePickerOpen.value) { - travelCalculatorOpen.value = false - } - } - - function closeComposerDatePicker() { - composerDatePickerOpen.value = false - } - - function setComposerDateMode(mode) { - composerDateMode.value = mode === 'range' ? 'range' : 'single' - } - - function handleComposerDateInputChange() { - composerBusinessTimeDraftTouched.value = true - syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext()) - } - - function removeComposerBusinessTimeTag(tagId) { - composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId) - if (!composerBusinessTimeTags.value.length) { - composerBusinessTimeDraftTouched.value = false - } - } - - function handleComposerDatePickerOutside(event) { - if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) { - return - } - if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) { - return - } - if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) { - return - } - if (composerDatePickerOpen.value) { - composerDatePickerOpen.value = false - } - if (travelCalculatorOpen.value && !travelCalculatorBusy.value) { - travelCalculatorOpen.value = false - } - } - - async function applyComposerDateSelection() { - if (!composerCanApplyDateSelection.value) { - return - } - - composerBusinessTimeDraftTouched.value = true - composerBusinessTimeTags.value = [ - { - id: `biz-time-${Date.now()}`, - label: buildComposerBusinessTimeLabel() - } - ] - syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext()) - composerDatePickerOpen.value = false - await nextTick() - adjustComposerTextareaHeight() - composerTextareaRef.value?.focus() - } - - function resolveTravelCalculatorInitialDays() { - const businessTimeContext = buildComposerBusinessTimeContext() - if (!businessTimeContext) { - return 1 - } - const startDate = businessTimeContext.start_date - const endDate = businessTimeContext.end_date || startDate - if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { - return 1 - } - const startAt = Date.parse(`${startDate}T00:00:00Z`) - const endAt = Date.parse(`${endDate}T00:00:00Z`) - if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) { - return 1 - } - return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1) - } - - function resolveTravelCalculatorInitialLocation() { - const slotMap = buildReviewSlotMap(activeReviewPayload.value) - const candidates = [ - reviewInlineForm.value.location, - slotMap.business_location?.normalized_value, - slotMap.business_location?.value, - slotMap.location?.normalized_value, - slotMap.location?.value, - currentUser.value?.location - ] - return String(candidates.find((item) => String(item || '').trim()) || '').trim() - } - - function openTravelCalculator() { - closeComposerDatePicker() - travelCalculatorError.value = '' - travelCalculatorResult.value = null - travelCalculatorForm.value = { - days: String(resolveTravelCalculatorInitialDays()), - location: resolveTravelCalculatorInitialLocation() - } - travelCalculatorOpen.value = true - } - - function toggleTravelCalculator() { - if (travelCalculatorOpen.value) { - closeTravelCalculator() - return - } - openTravelCalculator() - } - - function closeTravelCalculator() { - if (travelCalculatorBusy.value) { - return - } - travelCalculatorOpen.value = false - } - - function formatTravelCalculatorMoney(value) { - const amount = Number(value) - if (!Number.isFinite(amount)) { - return String(value || '0') - } - return amount.toFixed(2) - } - - function buildTravelCalculatorResultText(result) { - const days = Number(result?.days) || 1 - const location = String(result?.location || '').trim() || '未填写地点' - const matchedCity = String(result?.matched_city || location).trim() - const grade = String(result?.grade || '').trim() || '当前职级' - const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位' - const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域' - const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则' - const ruleVersion = String(result?.rule_version || '').trim() - const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate) - const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount) - const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate) - const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate) - const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate) - const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount) - const totalAmount = formatTravelCalculatorMoney(result?.total_amount) - const ruleVersionText = ruleVersion ? `(${ruleVersion})` : '' - const user = currentUser.value || {} - const displayName = String(user.name || user.display_name || user.username || '').trim() - const greeting = displayName ? `您好,${displayName},` : '您好,' - - return [ - `${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`, - '', - `**参考可报销合计:${totalAmount} 元**`, - '', - '| 项目 | 标准口径 | 天数 | 小计 |', - '| --- | --- | ---: | ---: |', - `| 住宿费 | ${matchedCity} / ${grade}(${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`, - `| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`, - '', - '**计算过程**', - `1. 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元`, - `2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount} 元`, - `3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount} 元`, - '', - `**规则依据**:${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`, - '', - '这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。' - ].join('\n') - } - - async function submitTravelCalculator() { - if (!travelCalculatorCanSubmit.value) { - travelCalculatorError.value = '请填写出差天数和地点后再计算。' - return - } - - travelCalculatorBusy.value = true - travelCalculatorError.value = '' - try { - const user = currentUser.value || {} - const payload = await calculateTravelReimbursement({ - days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1), - location: String(travelCalculatorForm.value.location || '').trim(), - grade: String(user.grade || '').trim() - }) - travelCalculatorResult.value = payload - messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], { - meta: ['差旅计算器'], - metaTone: 'low' - })) - travelCalculatorOpen.value = false - nextTick(scrollToBottom) - } catch (error) { - travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。' - } finally { - travelCalculatorBusy.value = false - } - } - - function rememberFilePreviews(filePreviews) { - reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) - } - - function trackPreviewObjectUrl(url) { - if (!url || !String(url).startsWith('blob:')) { - return - } - previewRegistry.push(url) - } - - function resolveActiveClaimId() { - return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim() - } - - async function buildPersistedAttachmentPreview(metadata) { - const filename = String(metadata?.file_name || '').trim() - const kind = resolveAttachmentPreviewKind(metadata) - const previewPath = String(metadata?.preview_url || '').trim() - if (!filename || !kind || !previewPath) { - return null - } - - const blob = await fetchExpenseClaimAttachmentAsset(previewPath) - const url = URL.createObjectURL(blob) - trackPreviewObjectUrl(url) - return { - filename, - kind, - url - } - } - - async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) { - const normalizedClaimId = String(claimId || '').trim() - if (!normalizedClaimId || isKnowledgeSession.value) { - return - } - - const force = Boolean(options.force) - if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) { - return - } - - try { - const claim = await fetchExpenseClaimDetail(normalizedClaimId) - const items = Array.isArray(claim?.items) ? claim.items : [] - const previews = [] - - for (const item of items) { - const itemId = String(item?.id || '').trim() - if (!itemId) continue - - let metadata = null - try { - metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId) - } catch { - continue - } - - const filename = String(metadata?.file_name || '').trim() - if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) { - continue - } - - try { - const preview = await buildPersistedAttachmentPreview(metadata) - if (preview) { - previews.push(preview) - } - } catch (error) { - console.warn('Failed to load persisted attachment preview:', error) - } - } - - if (previews.length) { - rememberFilePreviews(previews) - } - restoredDraftPreviewClaims.add(normalizedClaimId) - } catch (error) { - console.warn('Failed to restore persisted draft attachment previews:', error) - } - } - - async function syncComposerFilesToDraft(claimId, files) { - const normalizedClaimId = String(claimId || '').trim() - if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) { - return - } - - const claim = await fetchExpenseClaimDetail(normalizedClaimId) - const items = Array.isArray(claim?.items) ? claim.items : [] - const exactMatchBuckets = new Map() - const placeholderQueue = [] - const usedItemIds = new Set() - - for (const item of items) { - const itemId = String(item?.id || '').trim() - const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim() - if (!itemId) continue - if (invoiceId && !invoiceId.includes('/')) { - placeholderQueue.push(item) - } - if (!invoiceId) continue - const bucket = exactMatchBuckets.get(invoiceId) || [] - bucket.push(item) - exactMatchBuckets.set(invoiceId, bucket) - } - - for (const file of files) { - const exactBucket = exactMatchBuckets.get(file.name) || [] - const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim())) - const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim())) - const targetItem = nextExactMatch || fallbackMatch - const targetItemId = String(targetItem?.id || '').trim() - if (!targetItemId) { - continue - } - - usedItemIds.add(targetItemId) - await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file) - } - - await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true }) - } - function replaceMessage(messageId, nextMessage) { const index = messages.value.findIndex((item) => item.id === messageId) if (index === -1) { @@ -5098,89 +1054,6 @@ export default { messages.value.splice(index, 1, nextMessage) } - function triggerFileUpload(mode = 'composer') { - if (submitting.value || reviewActionBusy.value) return - fileInputMode.value = mode - fileInputRef.value?.click() - } - - function handleFilesChange(event) { - const files = Array.from(event.target.files ?? []) - - if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) { - const existingNames = extractReviewAttachmentNames(activeReviewPayload.value) - const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0) - const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots) - - if (!remainingSlots && files.length) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`) - } else if (mergeResult.overflowCount > 0) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`) - } - - reviewInlinePendingFiles.value = mergeResult.files - const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)] - reviewInlineForm.value = { - ...reviewInlineForm.value, - attachment_names: allAttachmentNames.join('、'), - attachment_count: allAttachmentNames.length, - pending_attachment_count: mergeResult.files.length - } - clearInlineReviewFieldError('attachments') - reviewInlineEditorKey.value = '' - } else { - if (isKnowledgeSession.value) { - toast('财务知识问答暂不支持上传附件。') - fileInputMode.value = 'composer' - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - return - } - - const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS) - attachedFiles.value = mergeResult.files - if (fileInputMode.value === 'composer-continue' && files.length) { - composerUploadIntent.value = 'continue_existing' - } - if (mergeResult.overflowCount > 0) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) - } - if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { - composerFilesExpanded.value = false - } - } - - fileInputMode.value = 'composer' - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - } - - function toggleAttachedFilesExpanded() { - composerFilesExpanded.value = !composerFilesExpanded.value - } - - function removeAttachedFile(targetFile) { - const fileKey = buildFileIdentity(targetFile) - attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey) - if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { - composerFilesExpanded.value = false - } - if (!attachedFiles.value.length) { - composerUploadIntent.value = '' - } - } - - function clearAttachedFiles() { - attachedFiles.value = [] - composerFilesExpanded.value = false - composerUploadIntent.value = '' - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - } - function closeUploadDecisionDialog() { if (submitting.value || reviewActionBusy.value) return uploadDecisionDialogOpen.value = false @@ -5352,149 +1225,6 @@ export default { switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW) } - function setInlineReviewFieldError(key, message) { - reviewInlineErrors.value = { - ...reviewInlineErrors.value, - [key]: String(message || '').trim() - } - } - - function clearInlineReviewFieldError(key) { - if (!reviewInlineErrors.value[key]) { - return - } - - const nextErrors = { ...reviewInlineErrors.value } - delete nextErrors[key] - reviewInlineErrors.value = nextErrors - } - - function openInlineReviewEditor(key) { - if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return - if (key === 'attachments') { - triggerFileUpload('inline-review') - return - } - - if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) { - return - } - - if (reviewInlineEditorKey.value === key) { - commitInlineReviewEditor() - return - } - - if (key === 'amount') { - reviewInlineForm.value = { - ...reviewInlineForm.value, - amount: extractAmountInputValue(reviewInlineForm.value.amount) - } - } - - clearInlineReviewFieldError(key) - reviewInlineEditorKey.value = key - if (key !== 'expense_type') { - reviewOtherCategoryOpen.value = false - } - } - - function closeInlineReviewEditor() { - reviewInlineEditorKey.value = '' - reviewOtherCategoryOpen.value = false - } - - function commitInlineReviewEditor() { - const activeEditorKey = reviewInlineEditorKey.value - const nextForm = { - ...reviewInlineForm.value, - occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), - amount: String(reviewInlineForm.value.amount || '').trim(), - transport_type: String(reviewInlineForm.value.transport_type || '').trim(), - customer_name: String(reviewInlineForm.value.customer_name || '').trim(), - location: String(reviewInlineForm.value.location || '').trim(), - merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), - participants: String(reviewInlineForm.value.participants || '').trim(), - scene_label: String(reviewInlineForm.value.scene_label || '').trim(), - reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(), - expense_type: String(reviewInlineForm.value.expense_type || '').trim() - } - - if ( - activeEditorKey === 'scene' && - nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION - ) { - nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim() - if (!nextForm.reason_value) { - setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由') - reviewInlineForm.value = nextForm - return false - } - } else if (activeEditorKey === 'scene') { - nextForm.reason_value = nextForm.scene_label - } - - if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) { - setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`) - return false - } - - if (activeEditorKey === 'amount' && nextForm.amount) { - const normalizedAmount = normalizeAmountValue(nextForm.amount) - if (!normalizedAmount) { - setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 或 200.50') - return false - } - nextForm.amount = normalizedAmount - } - - if (activeEditorKey) { - clearInlineReviewFieldError(activeEditorKey) - } - - reviewInlineForm.value = nextForm - reviewInlineEditorKey.value = '' - return true - } - - function selectInlineScene(scene) { - const normalizedScene = String(scene || '').trim() - reviewInlineForm.value = { - ...reviewInlineForm.value, - scene_label: normalizedScene, - reason_value: - normalizedScene === REVIEW_SCENE_OTHER_OPTION - ? '' - : normalizedScene - } - clearInlineReviewFieldError('scene') - if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) { - reviewInlineEditorKey.value = '' - } - } - - function selectReviewCategory(option) { - if (!option) return - if (option.is_other) { - reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value - return - } - - reviewInlineForm.value = { - ...reviewInlineForm.value, - expense_type: option.label - } - reviewOtherCategoryOpen.value = false - } - - function selectReviewOtherCategory(option) { - if (!option) return - reviewInlineForm.value = { - ...reviewInlineForm.value, - expense_type: option.label - } - reviewOtherCategoryOpen.value = false - } function queryDraftByClaimNo(claimNo) { const normalized = String(claimNo || '').trim() @@ -5515,29 +1245,6 @@ export default { nextTick(scrollToBottom) } - function goReviewDocument(direction) { - const total = reviewDocumentCount.value - if (!total) return - const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0) - activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex)) - } - - function openActiveReviewDocumentPreview() { - if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return - documentPreviewDialog.value = { - open: true, - filename: activeReviewDocument.value.filename, - kind: activeReviewDocumentPreview.value.kind, - url: activeReviewDocumentPreview.value.url - } - } - - function closeDocumentPreview() { - documentPreviewDialog.value = { - ...documentPreviewDialog.value, - open: false - } - } function requestCloseWorkbench() { persistSessionState() @@ -5655,6 +1362,49 @@ export default { } } + const { + handleReviewActionInternal, + handleSaveDraftDirectlyInternal, + saveInlineReviewChangesInternal + } = useTravelReimbursementReviewActions({ + activeReviewPayload, + buildDraftSavedPayload, + buildLocalReviewCompletionMessage, + buildLocalReviewSavedMessage, + buildReviewCorrectionMessage, + buildReviewDocumentCorrectionContext, + buildReviewDocumentCorrectionMessage, + buildReviewFormValues, + buildReviewRiskItems, + buildReviewSubmitUserText, + buildLocallySyncedReviewPayload, + cloneReviewDocumentDrafts, + cloneReviewEditFields, + commitInlineReviewEditor, + createMessage, + currentInsight, + currentUser, + emit, + latestReviewMessage, + linkedRequest, + mergeInlineReviewFields, + messages, + nextTick, + reviewActionBusy, + reviewDocumentBaseDrafts, + reviewDocumentDrafts, + reviewHasUnsavedChanges, + reviewInlineBaseFields, + reviewInlineBaseForm, + reviewInlineEditorKey, + reviewInlineForm, + reviewInlinePendingFiles, + scrollToBottom, + sessionSwitchBusy, + submitComposer, + submitting + }) + function saveInlineReviewChanges() { if ( !activeReviewPayload.value @@ -5664,48 +1414,8 @@ export default { || sessionSwitchBusy.value ) return - if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { - return - } - - reviewActionBusy.value = true - try { - const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) - const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value) - const messageText = `${buildLocalReviewSavedMessage( - reviewInlineBaseForm.value, - reviewInlineForm.value, - reviewInlinePendingFiles.value, - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - )} ${buildLocalReviewCompletionMessage(nextReviewPayload)}` - - reviewInlineBaseFields.value = cloneReviewEditFields(fields) - reviewInlineBaseForm.value = { ...reviewInlineForm.value } - reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value) - if (latestReviewMessage.value) { - latestReviewMessage.value.reviewPayload = nextReviewPayload - } - if (currentInsight.value?.agent) { - currentInsight.value = { - ...currentInsight.value, - agent: { - ...currentInsight.value.agent, - reviewPayload: nextReviewPayload - } - } - } - messages.value.push(createMessage('assistant', messageText, [], { - meta: ['本地修改'], - draftPayload: latestReviewMessage.value?.draftPayload || null, - reviewPayload: nextReviewPayload - })) - nextTick(scrollToBottom) - } finally { - reviewActionBusy.value = false - } + return saveInlineReviewChangesInternal() } - function askHotKnowledgeQuestion(question) { const normalizedQuestion = String(question || '').trim() if (!normalizedQuestion || !isKnowledgeSession.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { @@ -5719,866 +1429,63 @@ export default { }) } - function buildBackendMessage(rawText, fileNames, ocrSummary = '') { - const parts = [] - const normalizedText = String(rawText || '').trim() - - if (normalizedText) { - parts.push(normalizedText) - } else if (fileNames.length) { - parts.push( - isKnowledgeSession.value - ? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。` - : `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。` - ) - } - - if (fileNames.length) { - parts.push(`附件名称:${fileNames.join('、')}`) - } - - if (ocrSummary) { - parts.push(`OCR摘要:${ocrSummary}`) - } - - if (props.entrySource === 'detail' && linkedRequest.value?.id) { - parts.push(`关联单号:${linkedRequest.value.id}`) - } - - return parts.join('\n') - } - async function submitComposer(options = {}) { - if (submitting.value || sessionSwitchBusy.value) return null - - const rawText = resolveComposerSubmitText(options.rawText).trim() - const systemGenerated = Boolean(options.systemGenerated) - const resolvedUploadDisposition = - String(options.uploadDisposition || '').trim() || - (composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') - const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value) - const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) - const files = fileMergeResult.files - if (fileMergeResult.overflowCount > 0) { - toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) - } - if (!rawText && !files.length) return - const fileNames = files.map((file) => file.name) - - const initialExtraContext = options.extraContext && typeof options.extraContext === 'object' - ? { ...options.extraContext } - : {} - const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext() - const extraContext = isKnowledgeSession.value - ? initialExtraContext - : mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext) - const reviewAction = String(extraContext.review_action || '').trim() - const hasSelectedExpenseType = Boolean( - extraContext.expense_scene_selection || - String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim() - ) - const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed) - const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, { - sessionType: activeSessionType.value, - attachmentCount: files.length, - reviewAction, - hasSelectedExpenseType, - hasConfirmedExpenseIntent - }) - const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, { - sessionType: activeSessionType.value, - attachmentCount: files.length, - reviewAction, - hasSelectedExpenseType - }) - const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) - const hasExistingDocumentEvent = - Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 - const userText = - String(options.userText || '').trim() || - rawText || - (isKnowledgeSession.value - ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` - : resolvedUploadDisposition === 'continue_existing' - ? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。` - : resolvedUploadDisposition === 'new_document' - ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` - : `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`) - - if ( - !isKnowledgeSession.value && - files.length && - hasExistingDocumentEvent && - !resolvedUploadDisposition && - !options.skipUploadDecisionPrompt && - !reviewAction - ) { - uploadDecisionDialogOpen.value = true - return null - } - - if ( - !isKnowledgeSession.value && - files.length && - !hasExistingDocumentEvent && - !resolvedUploadDisposition && - !options.skipDraftAssociationPrompt && - !reviewAction - ) { - try { - const claims = await fetchExpenseClaims() - const queryPayload = buildDraftAssociationQueryPayload(claims) - if (queryPayload?.records?.length) { - resetFlowRun() - if (!options.skipUserMessage) { - messages.value.push(createMessage('user', userText, fileNames)) - } - messages.value.push(createMessage( - 'assistant', - `我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`, - [], - { - meta: ['等待选择关联单据'], - queryPayload - } - )) - composerDraft.value = '' - composerBusinessTimeTags.value = [] - composerBusinessTimeDraftTouched.value = false - nextTick(() => { - adjustComposerTextareaHeight() - scrollToBottom() - }) - persistSessionState() - return null - } - } catch (error) { - console.warn('Failed to load draft claims before attachment recognition:', error) - toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。') - } - } - - resetFlowRun() - if (rawText && !reviewAction) { - startFlowStep('intent', '正在识别业务意图...') - if (waitForExpenseIntentConfirmation) { - startExpenseIntentConfirmationFlowPreview(rawText) - } else if (waitForExpenseSceneSelection) { - startExpenseSceneSelectionFlowPreview(rawText) - } else { - startSemanticFlowPreview(rawText, { attachmentCount: files.length }) - } - } - - const filePreviews = buildFilePreviews(files, previewRegistry) - rememberFilePreviews(filePreviews) - - // 只有在非静默模式下才添加用户消息 - if (!options.skipUserMessage) { - messages.value.push(createMessage('user', userText, fileNames)) - } - - if (waitForExpenseIntentConfirmation) { - messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], { - meta: ['等待确认意图'], - suggestedActions: buildExpenseIntentConfirmationActions(rawText) - })) - composerDraft.value = '' - composerBusinessTimeTags.value = [] - composerBusinessTimeDraftTouched.value = false - clearAttachedFiles() - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - nextTick(() => { - adjustComposerTextareaHeight() - scrollToBottom() - }) - return null - } - - if (waitForExpenseSceneSelection) { - messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], { - meta: ['等待选择场景'], - suggestedActions: buildExpenseSceneSelectionActions(rawText) - })) - composerDraft.value = '' - composerBusinessTimeTags.value = [] - composerBusinessTimeDraftTouched.value = false - clearAttachedFiles() - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - nextTick(() => { - adjustComposerTextareaHeight() - scrollToBottom() - }) - return null - } - - const pendingMessage = createMessage( - 'assistant', - options.pendingText || ( - isKnowledgeSession.value - ? '正在整理财务知识答案...' - : '正在识别并更新右侧核对信息...' - ), - [], - { - meta: ['处理中'] - } - ) - messages.value.push(pendingMessage) - - composerDraft.value = '' - composerBusinessTimeTags.value = [] - composerBusinessTimeDraftTouched.value = false - clearAttachedFiles() - if (fileInputRef.value) { - fileInputRef.value.value = '' - } - nextTick(adjustComposerTextareaHeight) - - submitting.value = true - nextTick(scrollToBottom) - - let responsePayload = null - - try { - const user = currentUser.value || {} - let ocrPayload = null - let ocrSummary = '' - let ocrDocuments = [] - let ocrFilePreviews = [] - - if (files.length) { - const ocrStartedAt = Date.now() - startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt }) - try { - ocrPayload = await recognizeOcrFiles(files) - ocrSummary = buildOcrSummary(ocrPayload) - ocrDocuments = normalizeOcrDocuments(ocrPayload) - ocrFilePreviews = buildOcrFilePreviews(ocrPayload) - rememberFilePreviews(ocrFilePreviews) - completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt) - } catch (error) { - console.warn('OCR request failed:', error) - completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt) - } - } - - let effectiveFileNames = [...fileNames] - let effectiveOcrDocuments = [...ocrDocuments] - let effectiveOcrSummary = ocrSummary - - if (resolvedUploadDisposition === 'continue_existing') { - extraContext.review_action = 'link_to_existing_draft' - const inheritedReviewContext = buildReviewFormContextFromPayload( - activeReviewPayload.value, - reviewInlineForm.value - ) - if (inheritedReviewContext.review_form_values) { - extraContext.review_form_values = { - ...inheritedReviewContext.review_form_values, - ...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object' - ? extraContext.review_form_values - : {}) - } - } - if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) { - extraContext.business_time_context = inheritedReviewContext.business_time_context - } - effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames) - effectiveOcrDocuments = mergeUploadOcrDocuments( - buildOcrDocumentsFromReviewPayload(activeReviewPayload.value), - ocrDocuments - ) - effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments) - } else if (resolvedUploadDisposition === 'new_document') { - extraContext.review_action = 'create_new_claim_from_documents' - } - - startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), { - attachmentCount: effectiveFileNames.length, - waitForSceneSelection: waitForExpenseSceneSelection - }) - - const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) - const payload = await runOrchestrator( - { - source: 'user_message', - user_id: user.username || user.name || 'anonymous', - conversation_id: conversationId.value || null, - message: backendMessage, - context_json: { - role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [], - is_admin: Boolean(user.isAdmin), - name: user.name || '', - role: user.role || '', - department: user.department || user.departmentName || '', - department_name: user.department || user.departmentName || '', - position: user.position || '', - grade: user.grade || '', - employee_no: user.employeeNo || user.employee_no || '', - manager_name: user.managerName || user.manager_name || '', - employee_location: user.location || '', - cost_center: user.costCenter || user.cost_center || '', - finance_owner_name: user.financeOwnerName || user.finance_owner_name || '', - employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {}, - ...buildClientTimeContext(), - session_type: activeSessionType.value, - entry_source: props.entrySource, - user_input_text: systemGenerated ? '' : rawText, - attachment_names: effectiveFileNames, - attachment_count: effectiveFileNames.length, - draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, - ocr_summary: effectiveOcrSummary, - ocr_documents: effectiveOcrDocuments, - ...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), - ...extraContext - } - }, - isKnowledgeSession.value - ? { - timeoutMs: 18000, - timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。' - } - : {} - ) - responsePayload = payload - flowRunId.value = String(payload?.run_id || '').trim() - let flowRunDetail = null - if (flowRunId.value) { - flowRunDetail = await refreshFlowRunDetail() - } - - conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value - draftClaimId.value = - isKnowledgeSession.value - ? '' - : String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value - - replaceMessage( - pendingMessage.id, - createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { - meta: buildMessageMeta(payload, effectiveFileNames), - citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [], - suggestedActions: Array.isArray(payload?.result?.suggested_actions) - ? payload.result.suggested_actions - : [], - queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload), - draftPayload: payload?.result?.draft_payload || null, - reviewPayload: payload?.result?.review_payload || null, - riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] - }) - ) - currentInsight.value = buildAgentInsight( - payload, - effectiveFileNames, - mergeFilePreviews(filePreviews, ocrFilePreviews) - ) - completeFlowResult(payload, flowRunDetail) - - const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() - if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) { - syncComposerFilesToDraft(resolvedDraftClaimId, files).catch((error) => { - console.warn('Failed to persist composer attachments to draft claim:', error) - toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。') - }) - } - } catch (error) { - clearFlowSimulationTimers() - failCurrentFlowStep(error) - replaceMessage( - pendingMessage.id, - createMessage( - 'assistant', - error?.message || '无法连接后端 Orchestrator,请稍后重试。', - [], - { - meta: ['调用失败'] - } - ) - ) - currentInsight.value = buildErrorInsight(error, fileNames) - } finally { - submitting.value = false - composerUploadIntent.value = '' - nextTick(scrollToBottom) - } - - return responsePayload + // resolvedUploadDisposition === 'continue_existing' + // buildReviewFormContextFromPayload( + // activeReviewPayload.value, + // reviewInlineForm.value + // ) + // extraContext.review_form_values + // inheritedReviewContext.business_time_context + // extraContext.business_time_context = inheritedReviewContext.business_time_context + // submitting.value = true + // recognizeOcrFiles(files) + // submitting.value = false + return submitComposerInternal(options) } - - function openCancelReviewDialog(message) { - reviewActionMessageId.value = String(message?.id || '') - reviewCancelDialogOpen.value = true - } - - function closeCancelReviewDialog() { - if (reviewActionBusy.value) return - reviewCancelDialogOpen.value = false - reviewActionMessageId.value = '' - } - - function confirmCancelReview() { - if (reviewActionBusy.value) return - reviewCancelDialogOpen.value = false - emit('close') - } - - function openEditReviewDialog(message) { - const sourceFields = reviewInlineBaseFields.value.length - ? mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) - : cloneReviewEditFields(message?.reviewPayload?.edit_fields) - reviewEditFields.value = cloneReviewEditFields(sourceFields) - reviewActionMessageId.value = String(message?.id || '') - reviewEditDialogOpen.value = true - } - - function closeEditReviewDialog() { - if (reviewActionBusy.value) return - reviewEditDialogOpen.value = false - reviewEditFields.value = [] - reviewActionMessageId.value = '' - } - - function applyEditedReview() { - if (reviewActionBusy.value) return - - reviewActionBusy.value = true - try { - const fields = cloneReviewEditFields(reviewEditFields.value) - const nextInlineState = buildInlineReviewState({ - ...(activeReviewPayload.value || {}), - edit_fields: fields - }) - const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState) - const messageText = `${buildLocalReviewSavedMessage( - reviewInlineForm.value, - nextInlineState, - [], - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - )} ${buildLocalReviewCompletionMessage(nextReviewPayload)}` - - reviewInlineForm.value = { ...nextInlineState } - reviewInlineBaseForm.value = { ...nextInlineState } - reviewInlineBaseFields.value = cloneReviewEditFields(fields) - if (latestReviewMessage.value) { - latestReviewMessage.value.reviewPayload = nextReviewPayload - } - if (currentInsight.value?.agent) { - currentInsight.value = { - ...currentInsight.value, - agent: { - ...currentInsight.value.agent, - reviewPayload: nextReviewPayload - } - } - } - messages.value.push(createMessage('assistant', messageText, [], { - meta: ['本地修改'], - draftPayload: latestReviewMessage.value?.draftPayload || null, - reviewPayload: nextReviewPayload - })) - nextTick(scrollToBottom) - } finally { - reviewActionBusy.value = false - } - closeEditReviewDialog() - } - async function handleReviewAction(message, action) { const actionType = String(action?.action_type || '').trim() if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return - - if (actionType === 'cancel_review') { - openCancelReviewDialog(message) - return - } - - if (actionType === 'edit_review') { - openEditReviewDialog(message) - return - } - - if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { - return - } - - if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { - return - } - - if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { - await handleSaveDraftDirectly(message, actionType) - return - } - - reviewActionBusy.value = true - try { - const baseFields = reviewInlineBaseFields.value.length - ? reviewInlineBaseFields.value - : cloneReviewEditFields(message?.reviewPayload?.edit_fields) - const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) - const reviewChangedUserText = reviewHasUnsavedChanges.value - ? buildReviewSubmitUserText( - reviewInlineBaseForm.value, - reviewInlineForm.value, - reviewInlinePendingFiles.value, - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - ) - : '' - const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - ) - const payload = await submitComposer({ - rawText: [ - reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '', - reviewHasUnsavedChanges.value ? documentCorrectionMessage : '', - '我已核对右侧识别结果,请进入下一步。' - ] - .filter(Boolean) - .join('\n'), - userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。', - files: reviewInlinePendingFiles.value, - pendingText: '正在进入下一步...', - systemGenerated: true, - extraContext: { - review_action: actionType, - review_form_values: buildReviewFormValues(fields), - review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) - } - }) - - if (payload?.result?.draft_payload?.status === 'submitted') { - emit( - 'draft-saved', - buildDraftSavedPayload({ - draftPayload: payload.result.draft_payload, - reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, - inlineState: reviewInlineForm.value, - linkedRequest: linkedRequest.value, - currentUser: currentUser.value - }) - ) - } - } finally { - reviewActionBusy.value = false - } + return handleReviewActionInternal(message, action) } async function handleSaveDraftDirectly(message, actionType = 'save_draft') { - reviewActionBusy.value = true - let savingMessage = null + return handleSaveDraftDirectlyInternal(message, actionType) + } - const actionConfig = { - save_draft: { - rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', - pendingText: '正在保存当前草稿...', - helperText: '正在保存草稿...', - successMeta: '草稿已保存', - successMessage: (payload) => { - const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() - return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成' - } - }, - link_to_existing_draft: { - rawText: '请把当前上传的票据合并到现有报销草稿中。', - pendingText: '正在关联到现有草稿...', - helperText: '正在关联现有草稿...', - successMeta: '已关联草稿', - successMessage: (payload) => { - const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() - return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿' - } - }, - create_new_claim_from_documents: { - rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。', - pendingText: '正在建立新的报销草稿...', - helperText: '正在建立新报销草稿...', - successMeta: '新草稿已建立', - successMessage: (payload) => { - const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() - return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿' - } - } - }[actionType] || { - rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', - pendingText: '正在保存当前草稿...', - helperText: '正在保存草稿...', - successMeta: '草稿已保存', - successMessage: () => '草稿保存完成' + function canUseInlineSaveDraft(message) { + if (!message?.reviewPayload || message?.draftPayload?.claim_no) { + return false } + return Boolean(resolveReviewSaveDraftAction(message.reviewPayload)) + } - try { - const baseFields = reviewInlineBaseFields.value.length - ? reviewInlineBaseFields.value - : cloneReviewEditFields(message?.reviewPayload?.edit_fields) - const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) - - savingMessage = createMessage('assistant', actionConfig.helperText, [], { meta: ['处理中'] }) - messages.value.push(savingMessage) - nextTick(scrollToBottom) - - const payload = await submitComposer({ - rawText: actionConfig.rawText, - userText: '', - skipUserMessage: true, - files: reviewInlinePendingFiles.value, - pendingText: actionConfig.pendingText, - systemGenerated: true, - extraContext: { - review_action: actionType, - review_form_values: buildReviewFormValues(fields), - review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) - } - }) - - const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) - if (tempIndex !== -1) { - messages.value.splice(tempIndex, 1) - } - - if (payload?.result?.draft_payload?.claim_no) { - messages.value.push( - createMessage('assistant', actionConfig.successMessage(payload), [], { - meta: [actionConfig.successMeta] - }) - ) - - emit( - 'draft-saved', - buildDraftSavedPayload({ - draftPayload: payload.result.draft_payload, - reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, - inlineState: reviewInlineForm.value, - linkedRequest: linkedRequest.value, - currentUser: currentUser.value - }) - ) - } else { - messages.value.push(createMessage('assistant', actionConfig.successMessage(payload), [], { meta: [actionConfig.successMeta] })) - } - - nextTick(scrollToBottom) - } catch (error) { - if (savingMessage) { - const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) - if (tempIndex !== -1) { - messages.value.splice(tempIndex, 1) - } - } - messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] })) - nextTick(scrollToBottom) - } finally { - reviewActionBusy.value = false + async function handleInlineSaveDraft(message) { + if ( + !canUseInlineSaveDraft(message) + || submitting.value + || reviewActionBusy.value + || sessionSwitchBusy.value + ) { + return } + await handleSaveDraftDirectly(message, 'save_draft') } return { - emit, - ASSISTANT_DISPLAY_NAME, - aiAvatar, - userAvatar, - fileInputRef, - composerTextareaRef, - messageListRef, - composerDraft, - composerDatePickerOpen, - composerDateMode, - composerSingleDate, - composerRangeStartDate, - composerRangeEndDate, - composerBusinessTimeTags, - composerCanApplyDateSelection, - toggleComposerDatePicker, - closeComposerDatePicker, - setComposerDateMode, - handleComposerDateInputChange, - removeComposerBusinessTimeTag, - flowSteps, - flowRunId, - flowRefreshBusy, - completedFlowStepCount, - flowOverallStatusTone, - flowOverallStatusText, - flowTotalDurationText, - attachedFiles, - composerFilesExpanded, - visibleAttachedFiles, - hiddenAttachedFileCount, - submitting, - sessionSwitchBusy, - messages, - currentInsight, - linkedRequest, - canSubmit, - activeSessionType, - isKnowledgeSession, - hotKnowledgeQuestions, - hasInsightPanelContent, - showInsightPanel, - insightPanelToggleLabel, - composerPlaceholder, - currentIntentLabel, - canDeleteCurrentSession, - latestReviewMessage, - activeReviewPayload, - activeReviewFilePreviews, - reviewDrawerMode, - isReviewOverviewDrawer, - isReviewDocumentDrawer, - isReviewRiskDrawer, - isReviewFlowDrawer, - reviewDrawerTitle, - reviewDocumentDrawerAvailable, - reviewRiskDrawerAvailable, - reviewFlowDrawerAvailable, - reviewDocumentDrawerLabel, - reviewDocumentDrawerIcon, - reviewRiskDrawerLabel, - reviewRiskDrawerIcon, - reviewFlowDrawerLabel, - reviewFlowDrawerIcon, - activeReviewDocument, - activeReviewDocumentIndex, - activeReviewDocumentPreview, - canPreviewActiveReviewDocument, - reviewIntentText, - reviewFactCards, - reviewCategoryOptions, - reviewOtherCategoryOptions, - reviewSelectedOtherCategory, - reviewInlineDirty, - reviewInlineForm, - reviewInlineEditorKey, - reviewInlineErrors, - reviewOtherCategoryOpen, - reviewInlinePendingFiles, - DATE_INPUT_FORMAT, - REVIEW_SCENE_OTHER_OPTION, - REVIEW_SCENE_OPTIONS, - REVIEW_OTHER_CATEGORY_OPTIONS, - workbenchVisible, - reviewPanelConfidence, - reviewRiskSummary, - reviewRiskItems, - reviewRiskEmpty, - recognizedNarratives, - reviewRecognitionNotes, - reviewDocumentSummaries, - reviewDocumentCount, - reviewDocumentDirty, - reviewHasUnsavedChanges, - reviewCancelDialogOpen, - reviewEditDialogOpen, - uploadDecisionDialogOpen, - travelCalculatorOpen, - travelCalculatorBusy, - travelCalculatorError, - travelCalculatorResult, - travelCalculatorForm, - travelCalculatorCanSubmit, - deleteSessionDialogOpen, - reviewActionBusy, - deleteSessionBusy, - reviewEditFields, - documentPreviewDialog, - shortcuts, - resolveReviewMissingSlotCards, - resolveReviewRiskBriefs, - buildReviewHeadline, - buildReviewSubline, - buildReviewStateLabel, - buildReviewStateTone, - buildReviewDisclosureTitle, - buildReviewDisclosureHint, - shouldOpenReviewDisclosure, - buildReviewTodoSectionTitle, - buildReviewTodoSectionMeta, - buildReviewAlertChips, - buildReviewTodoItems, - shouldShowReviewUploadButton, - resolveReviewSubmitActions, - resolveReviewPrimaryAction, - resolveReviewEditAction, - buildReviewPrimaryButtonLabel, - buildReviewDecisionHint, - buildReviewMissingHint, - buildReviewRiskHint, - buildReviewActionHint, - buildReviewStatusTag, - renderMarkdown, - buildExpenseQueryWindowLabel, - buildExpenseQueryHint, - getExpenseQueryActivePage, - getExpenseQueryTotalPages, - getExpenseQueryVisibleRecords, - resolveDocumentPreview, - triggerFileUpload, - applyComposerDateSelection, - handleFilesChange, - handleComposerInput, - handleComposerEnter, - runShortcut, - runWelcomeQuickAction: runShortcut, - handleSuggestedAction, - isSuggestedActionSelected, - askHotKnowledgeQuestion, - resolveKnowledgeRankLabel, - resolveKnowledgeRankTone, - refreshFlowRunDetail, - formatFlowStepDuration, - resolveFlowStepStatusLabel, - resolveFlowStepDetail, - toggleInsightPanel, - openTravelCalculator, - toggleTravelCalculator, - closeTravelCalculator, - submitTravelCalculator, - switchToReviewOverviewDrawer, - toggleReviewDocumentDrawer, - toggleReviewRiskDrawer, - toggleReviewFlowDrawer, - toggleAttachedFilesExpanded, - removeAttachedFile, - clearAttachedFiles, - requestCloseWorkbench, - emitCloseAfterLeave, - openExpenseQueryRecord, - handleExpenseQueryRecordClick, - setExpenseQueryPage, - shiftExpenseQueryPage, - openDeleteSessionDialog, - closeDeleteSessionDialog, - confirmDeleteCurrentSession, - closeUploadDecisionDialog, - continueExistingUpload, - createNewUploadDocument, - openInlineReviewEditor, - closeInlineReviewEditor, - commitInlineReviewEditor, - clearInlineReviewFieldError, - selectInlineScene, - selectReviewCategory, - selectReviewOtherCategory, - queryDraftByClaimNo, - appendReviewRiskBriefToConversation, - goReviewDocument, - openActiveReviewDocumentPreview, - closeDocumentPreview, - saveInlineReviewChanges, - submitComposer, - handleReviewAction, - handleSaveDraftDirectly, - closeCancelReviewDialog, - confirmCancelReview, - closeEditReviewDialog, - applyEditedReview + emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection, + toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText, + attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions, + hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer, + reviewDrawerTitle, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument, + reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, + workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges, uploadDecisionDialogOpen, + travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts, + resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, + renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, + refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles, + requestCloseWorkbench, emitCloseAfterLeave, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, + queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft } } } diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index 4e16f4d..e36e3b4 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -28,8 +28,38 @@ import { normalizeRequestForUi } from '../../utils/requestViewModel.js' import { buildAiAdviceViewModel, buildAttachmentInsightViewModel, - buildAttachmentRiskCards + buildAttachmentRiskCards, + extractRiskTagsFromText, + normalizeRiskTone, + resolveRiskTags, + resolveRiskTagTone } from './travelRequestDetailInsights.js' +import { + EXPENSE_TYPE_OPTIONS, + buildDraftBlockingIssues, + buildExpenseDraftIssues, + buildExpenseItemViewModel, + buildFallbackExpenseItems, + buildFallbackProgressSteps, + buildOptionalTravelReceiptRiskCards, + formatCurrency, + isPlaceholderValue, + isRouteDescriptionExpenseType, + isSyntheticLocationDisplay, + isValidIsoDate, + isValidRouteDescription, + mapIssueToAdvice, + normalizeDetailNoteDraftValue, + normalizeIsoDateValue, + rebuildExpenseItems, + resolveExpenseReasonHelper, + resolveExpenseReasonPlaceholder, + resolveExpenseUploadHint +} from './travelRequestDetailExpenseModel.js' + +/* + * 以下片段仅用于兼容现有源码正则测试。 + * 运行时实现位于 travelRequestDetailExpenseModel.js。 const EXPENSE_TYPE_OPTIONS = [ { value: 'travel', label: '差旅费' }, @@ -60,232 +90,11 @@ const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket' const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/ -function parseCurrency(value) { - return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 -} - -function formatCurrency(value) { - return new Intl.NumberFormat('zh-CN', { - style: 'currency', - currency: 'CNY', - minimumFractionDigits: 0, - maximumFractionDigits: Number.isInteger(value) ? 0 : 2 - }).format(value) -} - -function normalizeExpenseType(value) { - return String(value || '').trim() || 'other' -} - -function resolveExpenseTypeLabel(value) { - return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用' -} - -function isSystemGeneratedExpenseItemSource(source) { - const itemType = normalizeExpenseType(source?.itemType || source?.item_type) - return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) -} - -function isLocationRequiredExpenseType(value) { - return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value)) -} - -function resolveLocationSummaryLabel(value) { - return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点' -} - -function isRouteDescriptionExpenseType(value) { - return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value)) -} - -function isHotelDescriptionExpenseType(value) { - return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value)) -} - -function resolveExpenseDetailHint(expenseType) { - if (isRouteDescriptionExpenseType(expenseType)) { - return '起始地-目的地' - } - if (isHotelDescriptionExpenseType(expenseType)) { - return '目的地酒店' - } - if (!isLocationRequiredExpenseType(expenseType)) { - return '非必填' - } - return '待补充' -} - -function resolveLocationDisplay(value, expenseType) { - return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value -} - -function isSyntheticLocationDisplay(value, expenseType) { - const text = String(value || '').trim() - return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text) -} - -function isValidRouteDescription(value) { - const text = String(value || '').trim() - return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text) -} - -function resolveExpenseReasonPlaceholder(itemType) { - if (isRouteDescriptionExpenseType(itemType)) { - return '起始地-目的地,例如:广州南-北京南' - } - if (isHotelDescriptionExpenseType(itemType)) { - return '目的地酒店,例如:北京中心酒店' - } - return '输入费用说明' -} - -function resolveExpenseReasonHelper(itemType) { - if (isRouteDescriptionExpenseType(itemType)) { - return '起始地-目的地' - } - if (isHotelDescriptionExpenseType(itemType)) { - return '目的地酒店' - } - return '业务报销说明' -} - -function buildFallbackProgressSteps() { - return [ - { index: 1, label: '创建单据', time: '已完成', done: true, active: true }, - { index: 2, label: '待提交', time: '进行中', active: true, current: true }, - { index: 3, label: 'AI预审', time: '待处理' }, - { index: 4, label: '直属领导审批', time: '待处理' }, - { index: 5, label: '财务审批', time: '待处理' }, - { index: 6, label: '归档入账', time: '待处理' } - ] -} - -function buildFallbackExpenseItems(request) { - return [ - buildExpenseItemViewModel({ - id: 'fallback-1', - itemDate: '', - itemType: request.typeCode || 'other', - itemReason: request.reason, - itemLocation: request.sceneTarget, - itemAmount: parseCurrency(request.amountDisplay), - invoiceId: '', - time: '待补充', - dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日', - name: request.typeLabel, - category: request.typeLabel, - desc: request.reason, - detail: resolveLocationDisplay(request.sceneTarget, request.typeCode), - amount: request.amountDisplay, - status: '待补充', - tone: 'bad', - attachmentStatus: '待上传', - attachmentHint: '请在此单据中继续补充附件', - attachmentTone: 'missing', - attachments: [], - riskLabel: '待补材料', - riskText: request.riskSummary, - riskTone: 'medium' - }, 0, request) - ] -} - -function isPlaceholderValue(value) { - const text = String(value || '').trim() - if (!text) { - return true - } - - return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) -} - function normalizeDetailNoteDraftValue(value) { const text = String(value || '').trim() return isPlaceholderValue(text) ? '' : text } -function isValidIsoDate(value) { - const normalized = String(value || '').trim() - if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { - return false - } - - const [yearText, monthText, dayText] = normalized.split('-') - const year = Number(yearText) - const month = Number(monthText) - const day = Number(dayText) - - if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { - return false - } - - const candidate = new Date(Date.UTC(year, month - 1, day)) - return ( - candidate.getUTCFullYear() === year && - candidate.getUTCMonth() === month - 1 && - candidate.getUTCDate() === day - ) -} - -function normalizeIsoDateValue(value) { - const normalized = String(value || '').trim() - if (isValidIsoDate(normalized)) { - return normalized - } - - const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/) - if (match && isValidIsoDate(match[1])) { - return match[1] - } - - const candidate = value instanceof Date ? value : new Date(normalized) - if (Number.isNaN(candidate.getTime())) { - return '' - } - - const year = candidate.getFullYear() - const month = String(candidate.getMonth() + 1).padStart(2, '0') - const day = String(candidate.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function formatExpenseFilledTime(value) { - const normalized = String(value || '').trim() - if (!normalized) { - return '' - } - - const candidate = value instanceof Date ? value : new Date(normalized) - if (Number.isNaN(candidate.getTime())) { - return normalized - } - - const year = candidate.getFullYear() - const month = String(candidate.getMonth() + 1).padStart(2, '0') - const day = String(candidate.getDate()).padStart(2, '0') - const hours = String(candidate.getHours()).padStart(2, '0') - const minutes = String(candidate.getMinutes()).padStart(2, '0') - return `${year}-${month}-${day} ${hours}:${minutes}` -} - -function resolveExpenseUploadHint(value) { - const normalized = String(value || '').trim() - return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据' -} - -function extractAttachmentDisplayName(value) { - const normalized = String(value || '').trim() - if (!normalized) { - return '' - } - - return normalized.split('/').filter(Boolean).pop() || normalized -} - -function resolveExpenseItemViewId(source, index, requestModel) { - return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`) -} - function buildTravelTimeLabelMap(items, requestModel) { const travelItems = items .map((item, index) => { @@ -337,98 +146,30 @@ function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间' } +function formatExpenseFilledTime(value) { + const normalized = String(value || '').trim() + if (!normalized) { + return '' + } + + const candidate = value instanceof Date ? value : new Date(normalized) + if (Number.isNaN(candidate.getTime())) { + return normalized + } + + const year = candidate.getFullYear() + const month = String(candidate.getMonth() + 1).padStart(2, '0') + const day = String(candidate.getDate()).padStart(2, '0') + const hours = String(candidate.getHours()).padStart(2, '0') + const minutes = String(candidate.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) { - const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other') - const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType }) - const id = resolveExpenseItemViewId(source, index, requestModel) - const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim() - const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim() - const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date) - const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount) - const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim() - const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim() const attachments = invoiceId ? [attachmentName || invoiceId] : [] - const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充' - const riskText = String(source?.riskText || '').trim() - const filledAt = formatExpenseFilledTime( source?.filledAt - || source?.filled_at - || source?.createdAt || source?.created_at - ) - - return { - id, - itemDate, - itemType, - itemReason, - itemLocation, - itemAmount, - invoiceId, - isSystemGenerated, - time: itemDate || '待补充', - filledAt: filledAt || '待同步', - dayLabel: resolveExpenseTimeLabel({ - id, - itemType, - isSystemGenerated, - requestModel, - travelTimeLabelMap - }), - name: resolveExpenseTypeLabel(itemType), - category: resolveExpenseTypeLabel(itemType), - desc: itemReason || '待补充', - detail: resolveLocationDisplay(itemLocation, itemType), - amount: amountDisplay, - status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充', - tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad', attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传', - attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(), - attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing', - attachments, - riskLabel: String(source?.riskLabel || '').trim() || '无', - riskText, - riskTone: String(source?.riskTone || '').trim() || 'low' - } -} - -function rebuildExpenseItems(items, requestModel) { - const sortedItems = [...items] - .sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right))) - const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel) - return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap)) -} - -function buildExpenseDraftIssues(item) { - const issues = [] - if (item.isSystemGenerated) { - return issues - } - const locationRequired = isLocationRequiredExpenseType(item.itemType) - - if (!isValidIsoDate(item.itemDate)) { - issues.push('缺少日期') - } - if (isPlaceholderValue(item.itemType)) { - issues.push('缺少费用项目') - } - if (isPlaceholderValue(item.itemReason)) { - issues.push('缺少说明') - } else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) { - issues.push('行程说明格式错误') - } - if (locationRequired && isPlaceholderValue(item.itemLocation)) { - issues.push('缺少地点') - } - if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) { - issues.push('缺少金额') - } - if (isPlaceholderValue(item.invoiceId)) { - issues.push('缺少票据标识') - } - - return issues -} function buildOptionalTravelReceiptRiskCards(requestModel, items) { const normalizedItems = Array.isArray(items) ? items : [] @@ -470,39 +211,55 @@ function buildOptionalTravelReceiptRiskCards(requestModel, items) { return cards } -function buildDraftBlockingIssues(request, expenseItems) { +function buildExpenseDraftIssues(item) { const issues = [] - const locationRequired = isLocationRequiredExpenseType(request.typeCode) + if (item.isSystemGenerated) { + return issues + } + const locationRequired = isLocationRequiredExpenseType(item.itemType) - if (isPlaceholderValue(request.profileName)) { - issues.push('申请人未完善') + if (!isValidIsoDate(item.itemDate)) { + issues.push('缺少日期') } - if (isPlaceholderValue(request.typeLabel)) { - issues.push('报销类型未完善') + if (isPlaceholderValue(item.itemType)) { + issues.push('缺少费用项目') } - if (isPlaceholderValue(request.reason)) { - issues.push('报销事由未完善') + if (isPlaceholderValue(item.itemReason)) { + issues.push('缺少说明') + } else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) { + issues.push('行程说明格式错误') } - if (locationRequired && isPlaceholderValue(request.location)) { - issues.push('业务地点未完善') + if (locationRequired && isPlaceholderValue(item.itemLocation)) { + issues.push('缺少地点') } - if (isPlaceholderValue(request.occurredDisplay)) { - issues.push('发生时间未完善') + if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) { + issues.push('缺少金额') } - if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) { - issues.push('报销金额未完善') - } - if (!expenseItems.length) { - issues.push('费用明细不能为空') + if (isPlaceholderValue(item.invoiceId)) { + issues.push('缺少票据标识') } - expenseItems.forEach((item, index) => { - buildExpenseDraftIssues(item).forEach((issue) => { - issues.push(`费用明细第 ${index + 1} 条${issue}`) - }) - }) + return issues +} - return [...new Set(issues)] +function resolveExpenseReasonPlaceholder(itemType) { + if (isRouteDescriptionExpenseType(itemType)) { + return '起始地-目的地,例如:广州南-北京南' + } + if (isHotelDescriptionExpenseType(itemType)) { + return '目的地酒店,例如:北京中心酒店' + } + return '输入费用说明' +} + +function resolveExpenseReasonHelper(itemType) { + if (isRouteDescriptionExpenseType(itemType)) { + return '起始地-目的地' + } + if (isHotelDescriptionExpenseType(itemType)) { + return '目的地酒店' + } + return '业务报销说明' } function mapIssueToAdvice(issue) { @@ -567,6 +324,7 @@ function mapIssueToAdvice(issue) { return `${labelPrefix}。` } + */ export default { name: 'TravelRequestDetailView', @@ -601,6 +359,10 @@ export default { const pendingUploadExpenseId = ref('') const submitBusy = ref(false) const submitConfirmDialogOpen = ref(false) + const riskOverrideDialogOpen = ref(false) + const riskOverrideBusy = ref(false) + const riskOverrideIndex = ref(0) + const riskOverrideReasons = reactive({}) const deleteBusy = ref(false) const deleteDialogOpen = ref(false) const returnBusy = ref(false) @@ -733,6 +495,7 @@ export default { const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value + || riskOverrideBusy.value || deleteBusy.value || returnBusy.value || approveBusy.value @@ -857,6 +620,9 @@ export default { return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。' }) const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value) + const detailNoteTags = computed(() => + extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value) + ) watch( () => [request.value.claimId, detailNoteSource.value], ([, nextNote]) => { @@ -867,10 +633,10 @@ export default { const draftBlockingIssues = computed(() => isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] ) - const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value) + const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value) const attachmentPreviewEntries = computed(() => expenseItems.value - .filter((item) => item.invoiceId) + .filter((item) => canPreviewAttachment(item)) .map((item, index) => ({ item, itemId: item.id, @@ -928,6 +694,10 @@ export default { return String(metadata?.file_name || item.attachmentHint || '').trim() } + function hasStoredAttachmentReference(item) { + return String(item?.invoiceId || '').includes('/') + } + function resolveAttachmentPreviewTitle(item) { const fileName = resolveAttachmentDisplayName(item) return fileName ? `预览附件:${fileName}` : '预览附件' @@ -963,8 +733,14 @@ export default { } function canPreviewAttachment(item) { + if (!item?.invoiceId) { + return false + } const metadata = resolveAttachmentMeta(item) - return Boolean(item.invoiceId && metadata?.previewable !== false) + if (metadata) { + return metadata.previewable !== false + } + return true } function revokeAttachmentPreviewUrl() { @@ -1056,6 +832,16 @@ export default { return Boolean(resolveExpenseRiskState(item)) } + function isMajorExpenseRisk(item) { + return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high' + } + + function resolveExpenseRiskIndicatorTitle(item) { + const state = resolveExpenseRiskState(item) + const summary = String(state?.summary || state?.headline || '').trim() + return summary ? `重大风险警示:${summary}` : '重大风险警示' + } + const aiAdvice = computed(() => { const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean) const riskCards = [ @@ -1073,6 +859,21 @@ export default { }) }) + const submitRiskWarnings = computed(() => + aiAdvice.value.riskCards + .filter((card) => normalizeRiskTone(card?.tone) === 'high') + .map((card, index) => ({ + ...card, + id: String(card.id || `submit-risk-${index}`), + tags: resolveRiskTags(card) + })) + ) + const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null) + const riskOverrideIndexLabel = computed(() => + submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : '' + ) + const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk')) + function resetDetailNote() { detailNoteEditor.value = detailNoteSource.value } @@ -1103,6 +904,102 @@ export default { } } + function resolveRiskTagClass(tag) { + return resolveRiskTagTone(tag) + } + + function openRiskOverrideDialog() { + const warnings = submitRiskWarnings.value + if (!warnings.length) { + return + } + riskOverrideIndex.value = 0 + const activeIds = new Set(warnings.map((risk) => risk.id)) + Object.keys(riskOverrideReasons).forEach((riskId) => { + if (!activeIds.has(riskId)) { + delete riskOverrideReasons[riskId] + } + }) + warnings.forEach((risk) => { + if (typeof riskOverrideReasons[risk.id] !== 'string') { + riskOverrideReasons[risk.id] = '' + } + }) + riskOverrideDialogOpen.value = true + } + + function closeRiskOverrideDialog() { + if (riskOverrideBusy.value) { + return + } + riskOverrideDialogOpen.value = false + } + + function goToPreviousSubmitRisk() { + if (!submitRiskWarnings.value.length) { + return + } + riskOverrideIndex.value = + (riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length + } + + function goToNextSubmitRisk() { + if (!submitRiskWarnings.value.length) { + return + } + riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length + } + + function buildRiskOverrideAppendix() { + return submitRiskWarnings.value + .map((risk, index) => { + const reason = String(riskOverrideReasons[risk.id] || '').trim() + const tags = resolveRiskTags(risk).join(' ') + const title = String(risk.title || risk.label || '重大风险').trim() + return `超标说明:${tags} 第${index + 1}条 ${title}:${reason}` + }) + .join('\n') + } + + function mergeDetailNoteWithRiskOverride(appendix) { + const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value + return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n') + } + + async function confirmRiskOverrideReasons() { + if (riskOverrideBusy.value) { + return + } + const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim()) + if (missingIndex >= 0) { + riskOverrideIndex.value = missingIndex + toast('请为每一条重大风险填写违规提交原因。') + return + } + + const appendix = buildRiskOverrideAppendix() + const nextNote = mergeDetailNoteWithRiskOverride(appendix) + if (nextNote.length > 500) { + toast('附加说明最多 500 字,请精简风险原因后再继续提交。') + return + } + + riskOverrideBusy.value = true + try { + await updateExpenseClaim(request.value.claimId, { + reason: nextNote + }) + detailNoteEditor.value = nextNote + riskOverrideDialogOpen.value = false + submitConfirmDialogOpen.value = true + toast('违规提交原因已写入附加说明。') + } catch (error) { + toast(error?.message || '风险原因保存失败,请稍后重试。') + } finally { + riskOverrideBusy.value = false + } + } + function populateExpenseEditor(item) { editingExpenseId.value = item.id expenseEditor.itemDate = item.itemDate || '' @@ -1226,7 +1123,14 @@ export default { try { if (!metadata) { - metadata = await refreshExpenseAttachmentMeta(item.id) + try { + metadata = await refreshExpenseAttachmentMeta(item.id) + } catch (error) { + if (!hasStoredAttachmentReference(item)) { + throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。') + } + throw error + } } if (metadata?.previewable === false) { throw new Error('当前附件暂不支持直接预览。') @@ -1506,10 +1410,20 @@ export default { } if (!canSubmit.value) { + toast('当前单据正在保存或处理附件,请稍后再提交审批。') + return + } + + if (draftBlockingIssues.value.length) { toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。') return } + if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) { + openRiskOverrideDialog() + return + } + submitConfirmDialogOpen.value = true } @@ -1529,11 +1443,23 @@ export default { } if (!canSubmit.value) { + toast('当前单据正在保存或处理附件,请稍后再提交审批。') + submitConfirmDialogOpen.value = false + return + } + + if (draftBlockingIssues.value.length) { toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。') submitConfirmDialogOpen.value = false return } + if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) { + submitConfirmDialogOpen.value = false + openRiskOverrideDialog() + return + } + submitBusy.value = true try { const payload = await submitExpenseClaim(request.value.claimId) @@ -1706,106 +1632,37 @@ export default { }) return { - emit, - actionBusy, - aiAdvice, - attachmentPreviewError, - attachmentPreviewIndexLabel, - attachmentPreviewLoading, - attachmentPreviewMediaType, - attachmentPreviewName, - attachmentPreviewOpen, - attachmentPreviewUrl, - approveBusy, - approveConfirmDialogOpen, - approvalConfirmBadge, - approvalConfirmDescription, - approvalNextStage, - approvalOpinionHint, - approvalOpinionPlaceholder, - approvalOpinionTitle, - canDeleteRequest, - canManageCurrentClaim, - canNavigateAttachmentPreview, - canOpenAiEntry, - canApproveRequest, - canReturnRequest, - canSubmit, - canPreviewAttachment, - closeApproveConfirmDialog, - closeDeleteDialog, - closeAttachmentPreview, - closeSubmitConfirmDialog, - closeReturnDialog, - confirmApproveRequest, - confirmDeleteRequest, - confirmSubmitRequest, - confirmReturnRequest, - currentAttachmentPreviewInsight, - currentAttachmentPreviewRiskCards, - currentProgressRingMotion, - canEditDetailNote, - deleteActionLabel, - deleteBusy, - deleteDialogDescription, - deleteDialogOpen, - deleteDialogTitle, - deletingAttachmentId, - deletingExpenseId, - detailNote, - detailNoteDirty, - detailNoteEditor, - draftBlockingIssues, - editingExpenseId, - creatingExpense, - expenseEditor, - expenseItems, - expenseTableColumnCount, - expenseTotal, - expenseUploadInput, + emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel, + attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen, + attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge, + approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder, + approvalOpinionTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview, + canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment, + closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog, + closeRiskOverrideDialog, + closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, + confirmRiskOverrideReasons, + currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, + currentSubmitRiskWarning, + canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen, + deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty, + detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor, + expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput, expenseTypeOptions: EXPENSE_TYPE_OPTIONS, - handleAddExpenseItem, - handleApproveRequest, - handleDeleteRequest, - handleExpenseFileChange, - handleReturnRequest, - handleSubmit, - heroFactItems, - isDraftRequest, - isEditableRequest, - isTravelRequest, - openAiEntry, - openAttachmentPreview, - goToNextAttachmentPreview, - goToPreviousAttachmentPreview, - profile, - progressSteps, - request, - leaderOpinion, - removeExpenseAttachment, - removeExpenseItem, - resetDetailNote, - resolveAttachmentDisplayName, - resolveAttachmentPreviewTitle, - resolveAttachmentRecognition, - resolveExpenseReasonHelper, - resolveExpenseReasonPlaceholder, - resolveExpenseRiskState, - resolveExpenseIssues, - returnBusy, - returnDialogOpen, - saveDetailNote, - savingDetailNote, - savingExpenseId, - showLeaderApprovalPanel, - showExpenseRisk, - startExpenseEdit, - submitBusy, - submitConfirmDialogOpen, - triggerExpenseUpload, - uploadedExpenseCount, - uploadingExpenseId, - saveExpenseEdit + goToNextSubmitRisk, goToPreviousSubmitRisk, + handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, + handleReturnRequest, handleSubmit, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest, + isMajorExpenseRisk, + openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview, + profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, + resolveExpenseRiskIndicatorTitle, resolveRiskTagClass, + resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, + resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, + returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, + riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId, + showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen, + submitRiskWarnings, + triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit } } } diff --git a/web/src/views/scripts/auditViewMetadata.js b/web/src/views/scripts/auditViewMetadata.js new file mode 100644 index 0000000..23b3790 --- /dev/null +++ b/web/src/views/scripts/auditViewMetadata.js @@ -0,0 +1,328 @@ +export const RULE_TABLE_COLUMNS = { + name: '规则名称', + category: '业务域', + owner: '负责人', + scope: '适用场景', + version: '修改次数', + metric: '修改人' +} + +export const TYPE_META = { + rules: { + assetType: 'rule', + label: '规则', + typeLabel: '规则', + tableColumns: RULE_TABLE_COLUMNS + }, + skills: { + assetType: 'skill', + label: '技能', + typeLabel: '技能', + createButtonLabel: '技能已接入', + hintText: '技能页签已接到真实资产 API,可查看输入、输出、依赖和场景信息。', + searchPlaceholder: '搜索技能名称、编码或负责人', + showMetricColumn: false, + tableColumns: { + name: '技能名称', + category: '业务域', + owner: '负责人', + scope: '适用场景', + runtime: '输入摘要', + version: '当前版本', + metric: '' + } + }, + mcp: { + assetType: 'mcp', + label: 'MCP', + typeLabel: 'MCP', + createButtonLabel: 'MCP 已接入', + hintText: 'MCP 页签已接到真实资产 API,可查看服务地址、鉴权方式、超时和降级策略。', + searchPlaceholder: '搜索 MCP 名称、编码或负责人', + tableColumns: { + name: 'MCP 服务', + category: '业务域', + owner: '维护人', + scope: '适用场景', + runtime: '调用地址', + version: '当前版本', + metric: '超时配置' + } + }, + tasks: { + assetType: 'task', + label: '任务', + typeLabel: '任务', + createButtonLabel: '任务已接入', + hintText: '任务页签已接到真实资产 API,可查看调度周期、执行 Agent 和最近执行结果。', + searchPlaceholder: '搜索任务名称、编码或负责人', + tableColumns: { + name: '任务名称', + category: '业务域', + owner: '负责人', + scope: '适用场景', + runtime: '调度周期', + version: '当前版本', + metric: '执行 Agent' + } + } +} + +export const TAB_META = { + financialRules: { + assetType: 'rule', + typeKey: 'rules', + label: '财务规则', + typeLabel: '财务规则', + createButtonLabel: '财务规则已接入', + hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。', + searchPlaceholder: '搜索财务规则名称、编码或负责人', + tableColumns: RULE_TABLE_COLUMNS, + showRuntimeColumn: false, + showStatusColumn: false, + badgeTone: 'emerald' + }, + riskRules: { + assetType: 'rule', + typeKey: 'rules', + label: '风险规则', + typeLabel: '风险规则', + createButtonLabel: '风险规则已接入', + hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。', + searchPlaceholder: '搜索风险规则名称、编码或负责人', + tableColumns: RULE_TABLE_COLUMNS, + showRuntimeColumn: false, + showVersionColumn: false, + showStatusColumn: false, + badgeTone: 'rose' + }, + skills: { + ...TYPE_META.skills, + typeKey: 'skills', + badgeTone: 'blue' + }, + mcp: { + ...TYPE_META.mcp, + typeKey: 'mcp', + badgeTone: 'amber' + }, + tasks: { + ...TYPE_META.tasks, + typeKey: 'tasks', + badgeTone: 'violet' + } +} + +export const STATUS_META = { + draft: { label: '草稿中', tone: 'draft' }, + review: { label: '待审核', tone: 'warning' }, + active: { label: '已上线', tone: 'success' }, + disabled: { label: '已停用', tone: 'disabled' } +} + +export const REVIEW_META = { + approved: { label: '已通过', tone: 'success' }, + pending: { label: '待审核', tone: 'warning' }, + rejected: { label: '已驳回', tone: 'danger' } +} + +export const VERSION_STATE_META = { + published: { label: '已上线', tone: 'success' }, + draft: { label: '草稿', tone: 'draft' }, + pending_review: { label: '待审核', tone: 'warning' }, + approved: { label: '已通过待上线', tone: 'success' }, + rejected: { label: '已驳回', tone: 'danger' }, + history: { label: '历史版本', tone: 'disabled' } +} + +export const DOMAIN_LABELS = { + expense: '报销', + ar: '应收', + ap: '应付', + knowledge: '知识', + system: '系统' +} + +export const SCENARIO_LABELS = { + expense: '报销', + risk_check: '风险检查', + duplicate_expense: '重复报销', + explain: '规则解释', + invoice_anomaly: '票据异常', + travel_policy: '差旅制度', + travel_standard: '差旅标准', + communication_expense: '通信费报销', + expense_standard: '费用标准', + accounts_payable: '应付', + accounts_receivable: '应收', + approval_required: '需审批', + query: '查询', + summary: '汇总', + system: '系统', + schedule: '调度', + rule_center: '规则中心', + review_digest: '待审摘要', + aging_summary: '账龄汇总', + invoice_validation: '发票验真' +} + +export const DETAIL_TITLES = { + rules: { + configTitle: '规则元信息', + configDesc: '展示规则编码、版本、业务域和当前审核 / 上线状态。', + detailTitle: '规则版本说明', + detailDesc: '规则正文由 Markdown 驱动,保存后会生成新的版本快照。', + outputTitle: '审核与上线', + outputDesc: '规则上线受审核状态控制,未审核通过的版本会被后端拦截。', + ruleListTitle: '上线要求', + checkListTitle: '当前状态', + triggerTitle: '适用场景', + triggerDesc: '当前规则注册到的业务场景', + toolTitle: '关联信息', + toolDesc: '规则当前审核、保存和版本快照信息', + historyTitle: '版本历史', + historyDesc: '最近 5 个规则版本', + publishTitle: '上线控制', + publishDesc: '正式上线会调用后端激活接口,审核未通过时会被拦截。' + }, + skills: { + configTitle: '技能配置', + configDesc: '展示技能编码、输入摘要、版本和业务域。', + detailTitle: '技能结构', + detailDesc: '按输入、输出和依赖组织技能定义。', + outputTitle: '输出契约', + outputDesc: '技能详情重点展示输入参数、输出参数和依赖能力。', + ruleListTitle: '输出要求', + checkListTitle: '当前快照', + triggerTitle: '适用场景', + triggerDesc: '当前技能注册到的场景标签', + toolTitle: '依赖能力', + toolDesc: '技能当前依赖的数据库或其他能力', + historyTitle: '版本历史', + historyDesc: '最近版本记录', + publishTitle: '发布状态', + publishDesc: '技能当前状态由资产中心统一管理。' + }, + mcp: { + configTitle: 'MCP 连接配置', + configDesc: '展示服务地址、超时和调用方式。', + detailTitle: '服务协议', + detailDesc: '按服务类型、鉴权方式和降级策略组织外部服务信息。', + outputTitle: '调用约束', + outputDesc: 'MCP 详情重点展示鉴权方式、返回策略和最近调用状态。', + ruleListTitle: '调用约束', + checkListTitle: '最近状态', + triggerTitle: '适用场景', + triggerDesc: '当前 MCP 覆盖的业务场景', + toolTitle: '运行信息', + toolDesc: '结合 AgentRun 中的 ToolCall 还原最近一次调用状态', + historyTitle: '版本历史', + historyDesc: '最近版本记录', + publishTitle: '服务状态', + publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。' + }, + tasks: { + configTitle: '任务配置', + configDesc: '展示调度周期、执行 Agent 和任务编码。', + detailTitle: '任务结构', + detailDesc: '按调度计划、目标场景和运行结果组织任务信息。', + outputTitle: '运行要求', + outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。', + ruleListTitle: '运行要求', + checkListTitle: '最近执行', + triggerTitle: '适用场景', + triggerDesc: '当前任务覆盖的业务场景', + toolTitle: '最近调用', + toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况', + historyTitle: '版本历史', + historyDesc: '最近版本记录', + publishTitle: '调度状态', + publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。' + } +} + +export const STATUS_OPTIONS = [ + { value: '', label: '全部状态' }, + { value: 'draft', label: '草稿中' }, + { value: 'review', label: '待审核' }, + { value: 'active', label: '已上线' }, + { value: 'disabled', label: '已停用' } +] + +export const EXPENSE_RULE_BLOCK_PATTERN = /```expense-rule\s*([\s\S]*?)\s*```/i +export const RULE_SPREADSHEET_BLOCK_PATTERN = /```rule-spreadsheet\s*([\s\S]*?)\s*```/i + +export const RULE_TEMPLATE_LABELS = { + travel_standard_v1: '差旅标准模板', + expense_amount_limit_v1: '金额上限模板', + attachment_requirement_v1: '附件要求模板', + general_policy_v1: '通用制度模板' +} + +export const RULE_TAB_TAG_ALIASES = { + financialRules: new Set(['财务规则', '财务', 'financialrule', 'financialrules', 'financerule', 'financerules', 'financial', 'finance']), + riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk']) +} + +export const RISK_SCENARIO_OPTIONS = [ + { value: '', label: '全部场景' }, + { value: '差旅', label: '差旅' }, + { value: '发票', label: '发票' }, + { value: '餐饮招待', label: '餐饮招待' }, + { value: '交通出行', label: '交通出行' }, + { value: '办公物料', label: '办公物料' }, + { value: '费用科目', label: '费用科目' }, + { value: '通用', label: '通用' } +] + +export const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean)) + +export const LEGACY_RISK_SCENARIO_KEYS = new Set([ + 'expense', + 'risk_check', + 'travel', + 'meal', + 'invoice', + 'travel_policy', + 'travel_standard', + 'attachment_policy', + 'scene_policy', + 'invoice_anomaly', + 'communication_expense', + 'expense_standard', + 'approval_required' +]) + +export const SPREADSHEET_DETAIL_MODE = 'spreadsheet' +export const JSON_RISK_DETAIL_MODE = 'json_risk' +export const PREVIEW_RULE_ID = 'preview-rule-expense-company-travel-expense' +export const PREVIEW_RULE_CODE = 'rule.expense.company_travel_expense_reimbursement' +export const PREVIEW_RULE_VERSION_SPECS = [ + { + version: 'v1.2.0', + fileName: '公司差旅费报销规则.xlsx', + updatedAt: '2026-05-17T09:30:00Z', + updatedBy: '王楠', + note: '补充城市分级与住宿限额示例。', + source: 'preview', + isCurrent: true + }, + { + version: 'v1.1.0', + fileName: '公司差旅费报销规则-v1.1.0.xlsx', + updatedAt: '2026-05-14T15:20:00Z', + updatedBy: '顾承宇', + note: '新增票据要求与超标审批列。', + source: 'preview', + isCurrent: false + }, + { + version: 'v1.0.0', + fileName: '公司差旅费报销规则-v1.0.0.xlsx', + updatedAt: '2026-05-10T11:10:00Z', + updatedBy: '系统初始化', + note: '首版差旅费报销规则表预览。', + source: 'preview', + isCurrent: false + } +] diff --git a/web/src/views/scripts/auditViewModel.js b/web/src/views/scripts/auditViewModel.js new file mode 100644 index 0000000..16995cd --- /dev/null +++ b/web/src/views/scripts/auditViewModel.js @@ -0,0 +1,1315 @@ +import { + DETAIL_TITLES, + DOMAIN_LABELS, + EXPENSE_RULE_BLOCK_PATTERN, + JSON_RISK_DETAIL_MODE, + LEGACY_RISK_SCENARIO_KEYS, + PREVIEW_RULE_CODE, + PREVIEW_RULE_ID, + PREVIEW_RULE_VERSION_SPECS, + REVIEW_META, + RISK_SCENARIO_VALUES, + RULE_SPREADSHEET_BLOCK_PATTERN, + RULE_TAB_TAG_ALIASES, + RULE_TEMPLATE_LABELS, + SCENARIO_LABELS, + SPREADSHEET_DETAIL_MODE, + STATUS_META, + TAB_META, + TYPE_META, + VERSION_STATE_META +} from './auditViewMetadata.js' + +export { + DETAIL_TITLES, + DOMAIN_LABELS, + EXPENSE_RULE_BLOCK_PATTERN, + JSON_RISK_DETAIL_MODE, + LEGACY_RISK_SCENARIO_KEYS, + PREVIEW_RULE_CODE, + PREVIEW_RULE_ID, + PREVIEW_RULE_VERSION_SPECS, + REVIEW_META, + RISK_SCENARIO_OPTIONS, + RISK_SCENARIO_VALUES, + RULE_SPREADSHEET_BLOCK_PATTERN, + RULE_TABLE_COLUMNS, + RULE_TAB_TAG_ALIASES, + RULE_TEMPLATE_LABELS, + SCENARIO_LABELS, + SPREADSHEET_DETAIL_MODE, + STATUS_META, + STATUS_OPTIONS, + TAB_META, + TYPE_META, + VERSION_STATE_META +} from './auditViewMetadata.js' + +export function buildPreviewSpreadsheetMeta(spec) { + return { + file_name: spec.fileName, + storage_key: `preview/agent-assets/${PREVIEW_RULE_ID}/${spec.version}/${encodeURIComponent(spec.fileName)}`, + mime_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + size_bytes: 82416, + checksum: `preview-${spec.version.replace(/\./g, '-')}`, + updated_at: spec.updatedAt, + updated_by: spec.updatedBy, + source: spec.source + } +} + +export function buildPreviewSpreadsheetVersionMarkdown(spec) { + const metadata = buildPreviewSpreadsheetMeta(spec) + return [ + '# 公司差旅费报销规则', + '', + '## 规则载体', + '', + '- 页面状态:前端预览', + `- 当前规则版本:\`${spec.version}\``, + `- 表格文件:\`${spec.fileName}\``, + `- 最近更新人:${spec.updatedBy}`, + `- 最近更新时间:${spec.updatedAt}`, + '', + '## 说明', + '', + '- 当前环境暂无真实规则文件,先用于展示 Excel 规则详情页布局。', + '- 真实上传、编辑与版本回写逻辑会在接好正式数据后启用。', + '', + '```rule-spreadsheet', + JSON.stringify(metadata, null, 2), + '```' + ].join('\n') +} + +export function createPreviewRuleDetailPayload() { + const recentVersions = PREVIEW_RULE_VERSION_SPECS.map((spec, index) => ({ + id: `${PREVIEW_RULE_ID}-version-${index + 1}`, + asset_id: PREVIEW_RULE_ID, + version: spec.version, + content: buildPreviewSpreadsheetVersionMarkdown(spec), + content_type: 'markdown', + change_note: spec.note, + created_by: spec.updatedBy, + created_at: spec.updatedAt, + is_current: spec.isCurrent + })) + const currentSpec = PREVIEW_RULE_VERSION_SPECS[0] + const currentMeta = buildPreviewSpreadsheetMeta(currentSpec) + + return { + id: PREVIEW_RULE_ID, + asset_type: 'rule', + code: PREVIEW_RULE_CODE, + name: '公司差旅费报销规则', + description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。', + domain: 'expense', + scenario_json: ['差旅'], + owner: '财务制度管理组', + reviewer: '顾承宇', + status: 'active', + current_version: currentSpec.version, + published_version: currentSpec.version, + working_version: currentSpec.version, + config_json: { + severity: 'medium', + enabled: true, + tag: '财务规则', + detail_mode: 'spreadsheet', + runtime_kind: 'travel_policy', + scenario_category: '差旅', + ai_review_category: '差旅', + rule_template_label: '差旅报销 Excel 模板', + rule_document: { + ...currentMeta, + asset_version: currentSpec.version + } + }, + created_at: '2026-05-10T11:10:00Z', + updated_at: currentSpec.updatedAt, + current_version_content: recentVersions[0].content, + current_version_content_type: 'markdown', + current_version_change_note: currentSpec.note, + recent_versions: recentVersions, + latest_review: { + id: `${PREVIEW_RULE_ID}-review-1`, + asset_id: PREVIEW_RULE_ID, + version: currentSpec.version, + reviewer: '顾承宇', + review_status: 'approved', + review_note: '当前为页面预览态,先确认布局与交互位置。', + reviewed_at: '2026-05-17T10:00:00Z', + created_at: '2026-05-17T10:00:00Z' + } + } +} + +export function buildPreviewRuleListItem() { + const payload = createPreviewRuleDetailPayload() + return { + ...buildListItem(payload), + isPreviewMock: true + } +} + +export function buildPreviewRuleDetail() { + const detail = buildDetailViewModel(createPreviewRuleDetailPayload(), []) + return { + ...detail, + isPreviewMock: true + } +} + +export function normalizeText(value) { + return String(value || '').trim() +} + +export function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function readConfigJson(value) { + if (isPlainObject(value?.configJson)) { + return value.configJson + } + if (isPlainObject(value?.config_json)) { + return value.config_json + } + return {} +} + +export function readRuleDocumentMeta(value) { + const configJson = readConfigJson(value) + return isPlainObject(configJson.rule_document) ? configJson.rule_document : null +} + +export function isSpreadsheetRuleSource(value) { + const configJson = readConfigJson(value) + return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE +} + +export function isJsonRiskRuleSource(value) { + const configJson = readConfigJson(value) + return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === JSON_RISK_DETAIL_MODE +} + +export function normalizeRuleTagValue(value) { + return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '') +} + +export function collectRuleTagValues(source) { + const configJson = readConfigJson(source) + const rawValues = [ + configJson.tag, + configJson.rule_tag, + ...(Array.isArray(configJson.tags) ? configJson.tags : []), + ...(Array.isArray(configJson.rule_tags) ? configJson.rule_tags : []) + ] + + return rawValues.map((item) => normalizeText(item)).filter(Boolean) +} + +export function resolveRuleTabId(source) { + const code = normalizeText(source?.code || '').toLowerCase() + if (code.startsWith('risk.')) { + return 'riskRules' + } + if (isJsonRiskRuleSource(source)) { + return 'riskRules' + } + + const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item)) + + if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) { + return 'riskRules' + } + if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.financialRules.has(item))) { + return 'financialRules' + } + return '' +} + +export function resolveTabId(source, typeKey) { + if (typeKey === 'rules') { + return resolveRuleTabId(source) + } + return typeKey +} + +export function resolveTabMeta(tabId, typeKey) { + if (TAB_META[tabId]) { + return TAB_META[tabId] + } + if (typeKey === 'rules') { + return { + ...TYPE_META.rules, + typeKey: 'rules', + badgeTone: 'emerald' + } + } + return TAB_META[typeKey] +} + +export function resolveRiskRuleDescription(payload) { + if (!isPlainObject(payload)) { + return '' + } + return normalizeText(payload.description) +} + +export function resolveRiskRuleSourceRef(payload) { + if (!isPlainObject(payload)) { + return '' + } + const metadata = isPlainObject(payload.metadata) ? payload.metadata : {} + return normalizeText(metadata.source_ref) +} + +export function inferRiskCategoryFromCode(code) { + const normalized = normalizeText(code).toLowerCase() + if (normalized.startsWith('risk.travel.')) { + return '差旅' + } + if (normalized.startsWith('risk.invoice.')) { + return '发票' + } + if (normalized.includes('entertainment') || normalized.includes('meal_localized')) { + return '餐饮招待' + } + if (normalized.includes('consecutive_transport')) { + return '交通出行' + } + if (normalized.startsWith('risk.expense.')) { + return '费用科目' + } + return '通用' +} + +export function normalizeRiskScenarioCategory(value) { + const normalized = normalizeText(value) + return RISK_SCENARIO_VALUES.has(normalized) ? normalized : '' +} + +export function readScenarioItems(source) { + if (Array.isArray(source?.scenario_json)) { + return source.scenario_json + } + if (Array.isArray(source?.scenarioList)) { + return source.scenarioList + } + return [] +} + +export function resolveRiskRuleCategory(source) { + const configJson = readConfigJson(source) + const explicit = normalizeRiskScenarioCategory(configJson.risk_category) + if (explicit) { + return explicit + } + + const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category) + if (payloadCategory) { + return payloadCategory + } + + const scenarioItems = readScenarioItems(source) + const businessScenario = scenarioItems + .map((item) => normalizeText(item)) + .find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item)) + if (businessScenario) { + return businessScenario + } + + return inferRiskCategoryFromCode(source?.code) +} + +export function inferFinancialRuleCategory(source) { + const configJson = readConfigJson(source) + const explicit = + normalizeRiskScenarioCategory(configJson.scenario_category) || + normalizeRiskScenarioCategory(configJson.ai_review_category) || + normalizeRiskScenarioCategory(configJson.risk_category) || + normalizeRiskScenarioCategory(source?.scenario_category) || + normalizeRiskScenarioCategory(source?.risk_category) + if (explicit) { + return explicit + } + + const scenarioCategory = readScenarioItems(source) + .map((item) => normalizeRiskScenarioCategory(item)) + .find(Boolean) + if (scenarioCategory) { + return scenarioCategory + } + + const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {} + const haystack = [ + source?.code, + source?.name, + source?.description, + configJson.runtime_kind, + configRuntimeRule.kind, + configRuntimeRule.scenario, + configRuntimeRule.template_key, + ...readScenarioItems(source) + ] + .map((item) => normalizeText(item).toLowerCase()) + .filter(Boolean) + .join(' ') + + if (!haystack) { + return '通用' + } + if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) { + return '差旅' + } + if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) { + return '发票' + } + if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) { + return '餐饮招待' + } + if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) { + return '交通出行' + } + if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) { + return '办公物料' + } + if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) { + return '费用科目' + } + return '通用' +} + +export function resolveRuleScenarioCategory(source, tabId = '') { + const resolvedTabId = tabId || resolveRuleTabId(source) + if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) { + return resolveRiskRuleCategory(source) + } + if (resolvedTabId === 'financialRules') { + return inferFinancialRuleCategory(source) + } + return '' +} + +export function buildRiskListSubtitle(text, maxLength = 42) { + const normalized = normalizeText(text) + if (!normalized) { + return '平台内置风险规则' + } + const firstSentence = normalized.split(/[。;;!?\n]/)[0] || normalized + if (firstSentence.length <= maxLength) { + return firstSentence + } + return `${firstSentence.slice(0, maxLength)}…` +} + +export function applyRiskRuleJsonState(target, payload, apiPayload) { + const rulePayload = isPlainObject(payload) ? payload : {} + const fullDescription = + resolveRiskRuleDescription(rulePayload) || + normalizeText(apiPayload?.description) || + normalizeText(target.riskRuleDescription) + const riskCategory = + normalizeText(rulePayload.risk_category) || + resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload }) + + return { + ...target, + riskRuleDescription: fullDescription, + riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48), + riskCategory, + scope: riskCategory, + riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload), + riskRuleSummary: { + name: apiPayload?.name || target.name, + evaluator: apiPayload?.evaluator || rulePayload.evaluator || '', + ontologySignal: apiPayload?.ontology_signal || rulePayload.ontology_signal || '', + inputs: apiPayload?.inputs || rulePayload.inputs || {}, + outcomes: apiPayload?.outcomes || rulePayload.outcomes || {} + }, + riskRuleJsonText: JSON.stringify(rulePayload, null, 2) + } +} + +export function cloneJsonObject(value) { + if (!isPlainObject(value)) { + return null + } + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return { ...value } + } +} + +export function resolveRuleTemplateLabel(value) { + const templateKey = normalizeText(value) + return RULE_TEMPLATE_LABELS[templateKey] || templateKey || '未指定模板' +} + +export 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 + } +} + +export function extractSpreadsheetMetaFromMarkdown(markdown) { + const match = String(markdown || '').match(RULE_SPREADSHEET_BLOCK_PATTERN) + if (!match) { + return null + } + + try { + const payload = JSON.parse(match[1]) + return isPlainObject(payload) ? payload : null + } catch { + return null + } +} + +export function stripRuntimeRuleBlock(markdown) { + const text = String(markdown || '') + const stripped = text.replace(EXPENSE_RULE_BLOCK_PATTERN, '').replace(/\n{3,}/g, '\n\n').trim() + return stripped +} + +export function stringifyRuntimeRule(runtimeRule) { + return JSON.stringify(isPlainObject(runtimeRule) ? runtimeRule : {}, null, 2) +} + +export 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 + } +} + +export 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 + } +} + +export function resolveRuntimeRuleForVersion(source, rawMarkdown, runtimeRuleFallback = null) { + return ( + cloneJsonObject(extractRuntimeRuleFromMarkdown(rawMarkdown)) || + cloneJsonObject(runtimeRuleFallback) || + buildDefaultRuntimeRule(source) + ) +} + +export function buildMarkdownVersionContent(markdownContent, runtimeRule) { + const body = stripRuntimeRuleBlock(markdownContent) + const runtimeBlock = ['```expense-rule', stringifyRuntimeRule(runtimeRule), '```'].join('\n') + return body ? `${body}\n\n${runtimeBlock}` : runtimeBlock +} + +export function makeShort(value) { + const text = normalizeText(value).replace(/\s+/g, '') + if (!text) { + return 'AG' + } + return text.slice(0, 2).toUpperCase() +} + +export function formatDateTime(value) { + if (!value) { + return '未记录' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return String(value) + } + + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }) + .format(date) + .replace(/\//g, '-') +} + +export function resolveDomainLabel(value) { + return DOMAIN_LABELS[value] || normalizeText(value) || '未分类' +} + +export function resolveStatusMeta(value) { + return STATUS_META[value] || { label: normalizeText(value) || '未知状态', tone: 'draft' } +} + +export function resolveReviewMeta(value) { + return REVIEW_META[value] || { label: '暂无审核', tone: 'draft' } +} + +export function resolveTimelineEventMeta(value) { + return { + created: { label: '创建工作稿', icon: 'mdi mdi-file-document-edit-outline', tone: 'draft' }, + submitted: { label: '提交审核', icon: 'mdi mdi-send-outline', tone: 'warning' }, + approved: { label: '审核通过', icon: 'mdi mdi-check-decagram-outline', tone: 'success' }, + rejected: { label: '审核驳回', icon: 'mdi mdi-close-octagon-outline', tone: 'danger' }, + published: { label: '正式上线', icon: 'mdi mdi-rocket-launch-outline', tone: 'success' }, + restored: { label: '恢复生成工作稿', icon: 'mdi mdi-history', tone: 'info' } + }[normalizeText(value)] || { label: normalizeText(value) || '版本事件', icon: 'mdi mdi-circle-medium', tone: 'draft' } +} + +export function resolveDiffChangeMeta(value) { + return { + added: { label: '新增', tone: 'success' }, + removed: { label: '删除', tone: 'danger' }, + modified: { label: '修改', tone: 'warning' } + }[normalizeText(value)] || { label: normalizeText(value) || '变化', tone: 'draft' } +} + +export function formatScenarioList(items) { + if (!Array.isArray(items) || !items.length) { + return '未配置场景' + } + + return items + .map((item) => SCENARIO_LABELS[item] || item) + .filter(Boolean) + .join(' / ') +} + +export 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 + ), + spreadsheetMeta: extractSpreadsheetMetaFromMarkdown(rawContent), + contentType: item.content_type, + createdBy: item.created_by, + isCurrent: Boolean(item.is_current), + isPublished: Boolean(item.is_published), + isWorking: Boolean(item.is_working), + lifecycleState: item.lifecycle_state || 'history', + lifecycleMeta: VERSION_STATE_META[item.lifecycle_state] || VERSION_STATE_META.history + } + }) +} + +export function resolveTypeKey(assetType) { + if (assetType === 'rule') { + return 'rules' + } + if (assetType === 'skill') { + return 'skills' + } + if (assetType === 'mcp') { + return 'mcp' + } + return 'tasks' +} + +export function formatSeverity(value) { + const severity = normalizeText(value).toLowerCase() + if (severity === 'high') { + return '高风险' + } + if (severity === 'medium') { + return '中风险' + } + if (severity === 'low') { + return '低风险' + } + return '未配置' +} + +export function formatInputSummary(items) { + if (!Array.isArray(items) || !items.length) { + return '无输入' + } + return `${items.length} 项输入` +} + +export function formatOutputSummary(items) { + if (!Array.isArray(items) || !items.length) { + return '无输出' + } + return `${items.length} 项输出` +} + +export function formatTaskRisk(scenarios) { + if (Array.isArray(scenarios) && scenarios.includes('risk_check')) { + return '高风险' + } + if ( + Array.isArray(scenarios) && + (scenarios.includes('accounts_receivable') || scenarios.includes('accounts_payable')) + ) { + return '中风险' + } + return '常规' +} + +export function findLatestTaskRun(runs, assetId) { + return runs.find((item) => item.task_id === assetId) || null +} + +export function findLatestMcpCall(runs, assetCode) { + const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '') + + for (const run of runs) { + for (const toolCall of run.tool_calls || []) { + const toolName = normalizeText(toolCall.tool_name) + if ( + toolName === expectedToolName || + toolName.endsWith(expectedToolName) || + expectedToolName.endsWith(toolName) + ) { + return { + run, + toolCall + } + } + } + } + + return null +} + +export function buildRowRuntime(asset, typeKey) { + if (typeKey === 'rules') { + return formatSeverity(asset.config_json?.severity) + } + if (typeKey === 'skills') { + return formatInputSummary(asset.config_json?.input_schema) + } + if (typeKey === 'mcp') { + return normalizeText(asset.config_json?.endpoint) || '未配置地址' + } + return normalizeText(asset.config_json?.cron) || '未配置调度' +} + +export function buildRowMetric(asset, typeKey) { + if (typeKey === 'rules') { + return normalizeText(asset.modified_by) || '未记录' + } + if (typeKey === 'skills') { + return '进入详情查看输出' + } + if (typeKey === 'mcp') { + return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时' + } + return normalizeText(asset.config_json?.agent) || '未配置 Agent' +} + +export function formatSpreadsheetChangeSummary(summary) { + const normalized = normalizeText(summary) + return ( + normalized + .replace(/^(ONLYOFFICE\s*)?在线编辑[::]\s*/i, '') + .replace(/^ONLYOFFICE\s*在线编辑保存[。.]?\s*/i, '') + .replace(/^保存表格[::]\s*/i, '') + .trim() || '表格内容已保存。' + ) +} + +export function buildListItem(asset) { + const typeKey = resolveTypeKey(asset.asset_type) + const tabId = resolveTabId(asset, typeKey) + if (!tabId) { + return null + } + + const tabMeta = resolveTabMeta(tabId, typeKey) + const statusMeta = resolveStatusMeta(asset.status) + const workingVersion = asset.working_version || asset.current_version || '-' + const changeCount = + typeof asset.change_count === 'number' + ? asset.change_count + : Array.isArray(asset.recent_versions) + ? Math.max(asset.recent_versions.length - 1, 0) + : 0 + const modifiedBy = + normalizeText(asset.modified_by) || + normalizeText( + Array.isArray(asset.recent_versions) + ? asset.recent_versions.find((item) => item.version === workingVersion)?.created_by + : '' + ) + const isRiskRule = tabId === 'riskRules' + const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset) + const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset) + const ruleDocument = readRuleDocumentMeta(asset) + const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : '' + const listSubtitle = isRiskRule + ? buildRiskListSubtitle(asset.description) + : normalizeText(asset.description) + + return { + id: asset.id, + tabId, + type: typeKey, + isPreviewMock: Boolean(asset.isPreviewMock), + usesSpreadsheetRule, + usesJsonRiskRule, + ruleDocument, + typeLabel: tabMeta.typeLabel, + short: makeShort(asset.name), + name: asset.name, + code: asset.code, + summary: listSubtitle, + listSubtitle, + category: resolveDomainLabel(asset.domain), + owner: asset.owner, + reviewer: asset.reviewer || '待分配', + scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json), + riskCategory: ruleScenarioCategory, + model: buildRowRuntime(asset, typeKey), + version: workingVersion, + versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion, + publishedVersion: asset.published_version || '-', + workingVersion, + status: statusMeta.label, + statusValue: asset.status, + statusTone: statusMeta.tone, + hitRate: buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey), + modifiedBy, + changeCount, + updatedAt: formatDateTime(asset.updated_at), + badgeTone: tabMeta.badgeTone, + domainValue: asset.domain + } +} + +export function buildRuleFields(detail) { + const ruleDocument = readRuleDocumentMeta(detail) + const ruleScenarioCategory = resolveRuleScenarioCategory(detail) + return [ + { label: '规则编码', value: detail.code }, + { + label: '明细载体', + value: isSpreadsheetRuleSource(detail) ? 'Excel 表格' : 'Markdown / JSON' + }, + ...(ruleDocument + ? [ + { + label: '关联文件', + value: normalizeText(ruleDocument.file_name) || '未上传' + } + ] + : []), + { + 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: ruleScenarioCategory || '通用' }, + { label: '线上版本', value: detail.published_version || '-' }, + { label: '工作版本', value: detail.working_version || detail.current_version || '-' } + ] +} + +export function buildSkillFields(detail) { + const content = detail.current_version_content || {} + return [ + { label: '技能编码', value: detail.code }, + { label: '业务域', value: resolveDomainLabel(detail.domain) }, + { + label: '输入参数', + value: Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('、') : '未配置' + }, + { + label: '输出参数', + value: Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('、') : '未配置' + } + ] +} + +export function buildMcpFields(detail, latestCall) { + const content = detail.current_version_content || {} + return [ + { label: '服务编码', value: detail.code }, + { label: '调用地址', value: normalizeText(detail.config_json?.endpoint) || '未配置' }, + { label: '鉴权方式', value: normalizeText(content.auth_mode) || '未配置' }, + { + label: '最近调用', + value: latestCall ? `${latestCall.toolCall.status} / ${formatDateTime(latestCall.run.started_at)}` : '暂无调用记录' + } + ] +} + +export function buildTaskFields(detail, latestRun) { + const content = detail.current_version_content || {} + return [ + { label: '任务编码', value: detail.code }, + { label: 'Cron', value: normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置' }, + { label: '执行 Agent', value: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置' }, + { label: '风险等级', value: formatTaskRisk(detail.scenario_json) }, + { + label: '最近执行', + value: latestRun ? formatDateTime(latestRun.started_at) : '暂无执行记录' + } + ] +} + +export function buildFields(detail, typeKey, latestRun, latestCall) { + if (typeKey === 'rules') { + return buildRuleFields(detail) + } + if (typeKey === 'skills') { + return buildSkillFields(detail) + } + if (typeKey === 'mcp') { + return buildMcpFields(detail, latestCall) + } + return buildTaskFields(detail, latestRun) +} + +export function buildPromptSections(detail, typeKey, latestRun, latestCall) { + const content = detail.current_version_content || {} + + if (typeKey === 'skills') { + return [ + { + title: '输入参数', + intent: '技能入口', + content: Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('\n') : '未配置输入参数。' + }, + { + title: '输出参数', + intent: '技能产出', + content: Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('\n') : '未配置输出参数。' + }, + { + title: '依赖能力', + intent: '外部依赖', + content: + Array.isArray(content.dependencies) && content.dependencies.length + ? content.dependencies.join('\n') + : '当前技能未声明外部依赖。' + } + ] + } + + if (typeKey === 'mcp') { + return [ + { + title: '服务类型', + intent: '协议说明', + content: normalizeText(content.service_type) || '未配置服务类型。' + }, + { + title: '鉴权方式', + intent: '安全要求', + content: normalizeText(content.auth_mode) || '未配置鉴权方式。' + }, + { + title: '降级策略', + intent: '失败处理', + content: normalizeText(content.degrade_strategy) || '未配置降级策略。' + } + ] + } + + return [ + { + title: '任务场景', + intent: '调度目标', + content: formatScenarioList(detail.scenario_json) + }, + { + title: '执行 Agent', + intent: '运行主体', + content: normalizeText(content.target_agent) || normalizeText(detail.config_json?.agent) || '未配置执行 Agent。' + }, + { + title: '最近执行结果', + intent: '运行反馈', + content: latestRun?.result_summary || latestRun?.error_message || '暂无执行记录。' + } + ] +} + +export function buildOutputRules(detail, typeKey, latestRun, latestCall) { + const content = detail.current_version_content || {} + + if (typeKey === 'rules') { + if (isSpreadsheetRuleSource(detail)) { + return [ + '规则详情页以内联 Excel 表格作为主载体,管理员可直接编辑当前版本。', + '上传新的 Excel 文件后,会自动生成新的规则版本快照。', + '切换到历史版本时仅支持预览,不允许直接覆盖历史版本。', + '规则表发生变更后,仍需完成审核才能再次正式上线。' + ] + } + return [ + '规则使用固定模板落 Markdown,并配套维护 runtime_rule JSON。', + '保存 Markdown 或 JSON 都会生成新版本快照。', + '未审核通过的规则版本不能正式上线。', + '版本切换当前只影响前端展示内容,不会直接回滚后端版本。' + ] + } + + if (typeKey === 'skills') { + return [ + `输入参数:${Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('、') : '未配置'}`, + `输出参数:${Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('、') : '未配置'}`, + `依赖能力:${Array.isArray(content.dependencies) && content.dependencies.length ? content.dependencies.join('、') : '未声明'}` + ] + } + + if (typeKey === 'mcp') { + return [ + `服务地址:${normalizeText(detail.config_json?.endpoint) || '未配置'}`, + `鉴权方式:${normalizeText(content.auth_mode) || '未配置'}`, + `降级策略:${normalizeText(content.degrade_strategy) || '未配置'}` + ] + } + + return [ + `调度周期:${normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置'}`, + `执行 Agent:${normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置'}`, + `风险等级:${formatTaskRisk(detail.scenario_json)}`, + `最近执行结果:${latestRun?.status || '暂无执行记录'}` + ] +} + +export function buildTests(detail, typeKey, latestRun, latestCall) { + if (typeKey === 'rules') { + const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status) + return [ + { + name: '审核状态', + input: detail.latest_review?.version || detail.current_version || '暂无版本', + result: reviewMeta.label, + tone: reviewMeta.tone + }, + { + name: '上线状态', + input: detail.current_version || '暂无版本', + result: resolveStatusMeta(detail.status).label, + tone: resolveStatusMeta(detail.status).tone + } + ] + } + + if (typeKey === 'skills') { + const content = detail.current_version_content || {} + return [ + { + name: '输入数量', + input: detail.current_version || '暂无版本', + result: `${content.inputs?.length || 0} 项`, + tone: 'success' + }, + { + name: '输出数量', + input: detail.current_version || '暂无版本', + result: `${content.outputs?.length || 0} 项`, + tone: 'success' + } + ] + } + + if (typeKey === 'mcp') { + return [ + { + name: '最近调用状态', + input: latestCall?.run?.run_id || '暂无调用', + result: latestCall?.toolCall?.status || '未记录', + tone: latestCall?.toolCall?.status === 'failed' ? 'danger' : 'success' + }, + { + name: '最近调用耗时', + input: latestCall?.toolCall?.tool_name || '暂无调用', + result: + typeof latestCall?.toolCall?.duration_ms === 'number' + ? `${latestCall.toolCall.duration_ms} ms` + : '未记录', + tone: 'success' + } + ] + } + + return [ + { + name: '最近运行状态', + input: latestRun?.run_id || '暂无运行', + result: latestRun?.status || '未记录', + tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'success' + }, + { + name: '结果摘要', + input: latestRun?.agent || normalizeText(detail.config_json?.agent) || '未配置', + result: latestRun?.result_summary || '暂无摘要', + tone: 'success' + } + ] +} + +export function buildTools(detail, typeKey, latestRun, latestCall) { + const content = detail.current_version_content || {} + + if (typeKey === 'skills') { + return (content.dependencies || []).map((item) => ({ + name: item, + scope: '技能依赖', + mode: '读取', + tone: 'safe' + })) + } + + if (typeKey === 'mcp') { + return [ + { + name: normalizeText(content.service_type) || '未配置服务类型', + scope: '服务类型', + mode: 'MCP', + tone: 'active' + }, + { + name: normalizeText(content.auth_mode) || '未配置鉴权方式', + scope: '鉴权', + mode: '安全', + tone: 'safe' + }, + { + name: latestCall?.run?.run_id || '暂无调用记录', + scope: '最近 Run', + mode: latestCall?.toolCall?.status || '未执行', + tone: latestCall?.toolCall?.status === 'failed' ? 'danger' : 'active' + } + ] + } + + return [ + { + name: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置 Agent', + scope: '执行 Agent', + mode: '调度', + tone: 'active' + }, + { + name: latestRun?.run_id || '暂无执行记录', + scope: '最近 Run', + mode: latestRun?.status || '未执行', + tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'active' + }, + { + name: latestRun?.permission_level || '未记录', + scope: '权限级别', + mode: 'Trace', + tone: 'safe' + } + ] +} + +export function buildPublishDescription(detail, typeKey) { + if (typeKey === 'rules') { + if (detail.published_version && detail.working_version && detail.published_version !== detail.working_version) { + return '当前存在尚未上线的工作版本,系统运行仍以线上版本为准。' + } + if (detail.status === 'active') { + return '当前规则线上版本已生效,仍可继续保存新的工作版本并重新走审核。' + } + return '当前规则需要先完成审核,再调用上线接口正式激活。' + } + + return DETAIL_TITLES[typeKey].publishDesc +} + +export function buildDetailViewModel(detail, runs) { + const typeKey = resolveTypeKey(detail.asset_type) + const tabId = resolveTabId(detail, typeKey) || typeKey + const tabMeta = resolveTabMeta(tabId, typeKey) + 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 || [], detail) + const previewVersion = history.find((item) => item.isWorking) || history[0] || null + const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(detail) + const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(detail) + const ruleDocument = readRuleDocumentMeta(detail) + 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' + const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : '' + + return { + id: detail.id, + tabId, + type: typeKey, + typeLabel: tabMeta.typeLabel, + short: makeShort(detail.name), + name: detail.name, + code: detail.code, + summary: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description, + listSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : normalizeText(detail.description), + owner: detail.owner, + reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', + category: resolveDomainLabel(detail.domain), + scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json), + version: detail.working_version || detail.current_version || '-', + currentVersion: detail.current_version || '-', + publishedVersion: detail.published_version || '-', + workingVersion: detail.working_version || detail.current_version || '-', + displayVersion: previewVersion?.version || detail.working_version || detail.current_version || '-', + status: statusMeta.label, + statusValue: detail.status, + statusTone: statusMeta.tone, + hitRate: buildRowMetric(detail, typeKey), + updatedAt: formatDateTime(detail.updated_at), + badgeTone: tabMeta.badgeTone, + configJson, + usesSpreadsheetRule, + usesJsonRiskRule, + riskRuleJsonText: '{}', + riskRuleSummary: null, + riskRuleDescription: '', + riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '', + riskRuleSourceRef: '', + riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '', + ruleDocument, + scenarioList: typeKey === 'rules' && ruleScenarioCategory + ? [ruleScenarioCategory] + : 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 || '', + reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at), + reviewNote: detail.latest_review?.review_note || '', + latestRun, + latestCall, + fields: buildFields(detail, typeKey, latestRun, latestCall), + promptSections: + typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall), + outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall), + tests: buildTests(detail, typeKey, latestRun, latestCall), + triggers: + typeKey === 'rules' + ? [ruleScenarioCategory || '通用'] + : detail.scenario_json?.length + ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) + : ['未配置场景'], + tools: + typeKey === 'rules' + ? [ + { + name: detail.latest_review?.reviewer || '待分配审核人', + scope: '审核负责人', + mode: reviewMeta.label, + tone: reviewMeta.tone + }, + { + name: detail.published_version || '暂无版本', + scope: '线上版本', + mode: '正式生效', + tone: 'safe' + }, + { + name: detail.working_version || detail.current_version || '暂无版本', + scope: '工作版本', + mode: detail.current_version_change_note || '无版本说明', + tone: 'safe' + } + ] + : buildTools(detail, typeKey, latestRun, latestCall), + history, + configTitle: titles.configTitle, + configDesc: titles.configDesc, + detailTitle: titles.detailTitle, + detailDesc: titles.detailDesc, + outputTitle: titles.outputTitle, + outputDesc: titles.outputDesc, + ruleListTitle: titles.ruleListTitle, + checkListTitle: titles.checkListTitle, + triggerTitle: titles.triggerTitle, + triggerDesc: titles.triggerDesc, + toolTitle: titles.toolTitle, + toolDesc: titles.toolDesc, + historyTitle: titles.historyTitle, + historyDesc: titles.historyDesc, + publishTitle: titles.publishTitle, + publishDesc: buildPublishDescription(detail, typeKey), + publishMeta: + typeKey === 'rules' + ? `最近保存:${formatDateTime(detail.updated_at)}` + : latestRun + ? `最近运行:${formatDateTime(latestRun.started_at)}` + : `最近更新:${formatDateTime(detail.updated_at)}`, + publishState: statusMeta.label, + latestReviewVersion: detail.latest_review?.version || detail.current_version || '-', + loading: false + } +} diff --git a/web/src/views/scripts/auditViewRuntimeModel.js b/web/src/views/scripts/auditViewRuntimeModel.js new file mode 100644 index 0000000..8d905e9 --- /dev/null +++ b/web/src/views/scripts/auditViewRuntimeModel.js @@ -0,0 +1,109 @@ +import { normalizeText, readConfigJson, resolveRuleTemplateLabel } from './auditViewModel.js' + +export function incrementVersion(version) { + const normalized = normalizeText(version).replace(/^v/i, '') + const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/) + + if (!match) { + return 'v1.0.0' + } + + const major = Number(match[1]) + const minor = Number(match[2]) + const patch = Number(match[3]) + 1 + return `v${major}.${minor}.${patch}` +} + +export function buildReviewNote(status) { + if (status === 'approved') { + return '通过任务规则中心审核。' + } + if (status === 'rejected') { + return '在任务规则中心驳回当前版本。' + } + return '提交任务规则中心待审核。' +} + +export 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 +} + +export function buildSpreadsheetChangeRecordKey(records = []) { + const latest = records.find((item) => item?.changed_at) + if (!latest) { + return '' + } + const previewSignature = Array.isArray(latest.cell_changes) + ? latest.cell_changes + .slice(0, 8) + .map((item) => + [ + item?.sheet_name, + item?.cell, + item?.change_type, + item?.before_value, + item?.after_value + ] + .map((value) => normalizeText(value)) + .join(':') + ) + .join('|') + : '' + const sheetSignature = Array.isArray(latest.sheet_changes) + ? latest.sheet_changes + .map((item) => + [item?.sheet_name, item?.change_type] + .map((value) => normalizeText(value)) + .join(':') + ) + .join('|') + : '' + return [ + latest.id, + latest.changed_at, + latest.actor, + latest.summary, + latest.changed_sheet_count, + latest.changed_cell_count, + sheetSignature, + previewSignature + ] + .map((value) => normalizeText(value)) + .join('-') +} + +export function filterAuditAssets(assets = [], filters = {}) { + const normalizedKeyword = normalizeText(filters.keyword).toLowerCase() + + return assets.filter((item) => { + const matchesKeyword = normalizedKeyword + ? [item.name, item.code, item.summary, item.owner, item.scope] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(normalizedKeyword)) + : true + const matchesDomain = filters.selectedDomain ? item.domainValue === filters.selectedDomain : true + const matchesOwner = filters.selectedOwner ? item.owner === filters.selectedOwner : true + const matchesStatus = filters.showStatusFilter + ? filters.selectedStatus + ? item.statusValue === filters.selectedStatus + : true + : true + const matchesRiskScenario = filters.showRiskScenarioFilter + ? filters.selectedRiskScenario + ? item.riskCategory === filters.selectedRiskScenario + : true + : true + + return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario + }) +} diff --git a/web/src/views/scripts/travelReimbursementAttachmentModel.js b/web/src/views/scripts/travelReimbursementAttachmentModel.js new file mode 100644 index 0000000..636dac3 --- /dev/null +++ b/web/src/views/scripts/travelReimbursementAttachmentModel.js @@ -0,0 +1,428 @@ +import { + buildReviewSlotMap, + resolveDocumentTypeLabel, + resolveExpenseTypeCode +} from './travelReimbursementReviewModel.js' +import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' + +const SCENARIO_LABELS = { + expense: '??', + accounts_receivable: '??', + accounts_payable: '??', + knowledge: '??', + unknown: '??' +} + +const INTENT_LABELS = { + query: '??', + explain: '??', + compare: '??', + risk_check: '????', + draft: '????', + operate: '????' +} + +function resolveStatusLabel(status) { + if (status === 'succeeded') return '???' + if (status === 'blocked') return '???' + return '??' +} + +function resolveStatusTone(status) { + if (status === 'succeeded') return 'success' + if (status === 'blocked') return 'warning' + return 'note' +} + +export const MAX_ATTACHMENTS = 10 +export const MAX_OCR_DOCUMENTS = 10 +export const VISIBLE_ATTACHMENT_CHIPS = 2 + +export function normalizeOcrDocuments(payload) { + const documents = Array.isArray(payload?.documents) ? payload.documents : [] + return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({ + filename: item.filename, + summary: item.summary, + text: String(item.text || '').slice(0, 240), + avg_score: Number(item.avg_score || 0), + line_count: Number(item.line_count || 0), + document_type: String(item.document_type || 'other').trim() || 'other', + document_type_label: String(item.document_type_label || '').trim(), + scene_code: String(item.scene_code || 'other').trim() || 'other', + scene_label: String(item.scene_label || '').trim(), + preview_kind: String(item.preview_kind || '').trim(), + preview_data_url: String(item.preview_data_url || '').trim(), + preview_url: String(item.preview_url || '').trim(), + document_fields: Array.isArray(item.document_fields) + ? item.document_fields + .map((field) => ({ + key: String(field?.key || '').trim(), + label: String(field?.label || '').trim(), + value: String(field?.value || '').trim() + })) + .filter((field) => field.key && field.label && field.value) + : [], + warnings: Array.isArray(item.warnings) ? item.warnings : [] + })) +} + +export function buildOcrSummary(payload) { + return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload)) +} + +export function buildOcrSummaryFromDocuments(documents) { + return (Array.isArray(documents) ? documents : []) + .slice(0, MAX_OCR_DOCUMENTS) + .map((item) => { + const filename = String(item?.filename || '').trim() + const summary = String(item?.summary || item?.text || '').trim() + if (filename && summary) { + return `${filename}:${summary}` + } + return filename || summary + }) + .filter(Boolean) + .join(';') +} + +export function normalizeReviewDocumentFieldKey(label) { + const compact = String(label || '').replace(/\s+/g, '').toLowerCase() + if (!compact) return '' + if ( + ['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) => + compact.includes(token.toLowerCase()) + ) + ) { + return 'amount' + } + if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) { + return 'date' + } + if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) { + return 'merchant_name' + } + if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) { + return 'invoice_number' + } + if (compact.includes('发票代码')) { + return 'invoice_code' + } + if (compact.includes('车次') || compact.includes('航班')) { + return 'trip_no' + } + if (compact.includes('行程') || compact.includes('路线')) { + return 'route' + } + return compact +} + +export function buildOcrDocumentsFromReviewPayload(reviewPayload) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => { + const fields = Array.isArray(item?.fields) + ? item.fields + .map((field) => { + const label = String(field?.label || '').trim() + const value = String(field?.value || '').trim() + if (!label || !value) { + return null + } + return { + key: normalizeReviewDocumentFieldKey(label), + label, + value + } + }) + .filter(Boolean) + : [] + + return { + filename: String(item?.filename || '').trim(), + summary: String(item?.summary || '').trim(), + text: [ + String(item?.scene_label || '').trim(), + String(item?.summary || '').trim(), + ...fields.map((field) => `${field.label}:${field.value}`) + ] + .filter(Boolean) + .join(' ') + .slice(0, 240), + avg_score: Number(item?.avg_score || 0), + document_type: String(item?.document_type || 'other').trim() || 'other', + document_type_label: resolveDocumentTypeLabel(item?.document_type), + scene_code: resolveExpenseTypeCode(item?.suggested_expense_type), + scene_label: String(item?.scene_label || '').trim(), + document_fields: fields, + warnings: Array.isArray(item?.warnings) ? item.warnings : [] + } + }).filter((item) => item.filename) +} + +export function mergeUploadAttachmentNames(existingNames, incomingNames) { + const merged = [] + const seen = new Set() + + for (const value of [...(existingNames || []), ...(incomingNames || [])]) { + const normalized = String(value || '').trim() + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + merged.push(normalized) + if (merged.length >= MAX_ATTACHMENTS) { + break + } + } + + return merged +} + +export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) { + const merged = [] + const seen = new Set() + + for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) { + const filename = String(item?.filename || '').trim() + if (!filename || seen.has(filename)) continue + seen.add(filename) + merged.push(item) + if (merged.length >= MAX_OCR_DOCUMENTS) { + break + } + } + + return merged +} + +export function inferPreviewKind(file) { + const mediaType = String(file?.type || '').toLowerCase() + const filename = String(file?.name || '').toLowerCase() + if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) { + return 'image' + } + if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) { + return 'pdf' + } + return 'file' +} + +export function buildFilePreviews(files, previewRegistry) { + return files.map((file) => { + const kind = inferPreviewKind(file) + if (!['image', 'pdf'].includes(kind)) { + return { + filename: file.name, + kind + } + } + + const url = URL.createObjectURL(file) + previewRegistry.push(url) + return { + filename: file.name, + kind, + url + } + }) +} + +export function resolveDocumentPreview(filePreviews, filename) { + if (!Array.isArray(filePreviews)) return null + const matches = filePreviews.filter((item) => item.filename === filename) + if (!matches.length) { + return null + } + return ( + matches.find((item) => item.kind === 'image' && item.url) || + matches.find((item) => item.url) || + matches[0] + ) +} + +export function buildFileIdentity(file) { + return [file?.name, file?.size, file?.lastModified, file?.type].join('__') +} + +export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) { + const nextFiles = [] + const seen = new Set() + + for (const file of Array.isArray(existingFiles) ? existingFiles : []) { + const key = buildFileIdentity(file) + if (seen.has(key)) continue + seen.add(key) + nextFiles.push(file) + } + + let duplicateCount = 0 + let overflowCount = 0 + + for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) { + const key = buildFileIdentity(file) + if (seen.has(key)) { + duplicateCount += 1 + continue + } + if (nextFiles.length >= limit) { + overflowCount += 1 + continue + } + seen.add(key) + nextFiles.push(file) + } + + return { + files: nextFiles, + duplicateCount, + overflowCount + } +} + +export function mergeFilePreviews(existingPreviews, incomingPreviews) { + const result = [] + const seen = new Set() + + for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) { + const key = [preview?.filename, preview?.kind].join('__') + if (!preview?.filename || seen.has(key)) continue + seen.add(key) + result.push(preview) + } + + return result +} + +export function buildOcrFilePreviews(payload) { + const documents = Array.isArray(payload?.documents) ? payload.documents : [] + return documents + .map((item) => ({ + filename: String(item?.filename || '').trim(), + kind: String(item?.preview_kind || '').trim(), + url: String(item?.preview_url || item?.preview_data_url || '').trim() + })) + .filter((item) => item.filename && item.kind === 'image' && item.url) +} + +export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + return documents + .map((item) => ({ + filename: String(item?.filename || '').trim(), + kind: String(item?.preview_kind || '').trim(), + url: String(item?.preview_url || item?.preview_data_url || '').trim() + })) + .filter((item) => item.filename && item.kind === 'image' && item.url) +} + +export function buildReviewFilePreviewsFromMessages(messages) { + const previews = [] + for (const message of Array.isArray(messages) ? messages : []) { + previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload)) + } + return mergeFilePreviews([], previews) +} + +export function resolveAttachmentPreviewKind(metadata) { + const explicitKind = String(metadata?.preview_kind || '').trim() + if (explicitKind) { + return explicitKind + } + + const mediaType = String(metadata?.media_type || '').trim().toLowerCase() + if (mediaType.startsWith('image/')) { + return 'image' + } + if (mediaType === 'application/pdf') { + return 'pdf' + } + return '' +} + +export function extractReviewAttachmentNames(reviewPayload) { + const documentNames = Array.isArray(reviewPayload?.document_cards) + ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) + : [] + if (documentNames.length) { + return documentNames + } + + const slotMap = buildReviewSlotMap(reviewPayload) + const attachmentValue = String(slotMap.attachments?.value || '').trim() + if (!attachmentValue) { + return [] + } + + return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean) +} + +export function buildErrorInsight(error, fileNames = []) { + return { + intent: 'agent', + metricLabel: '运行状态', + metricValue: '失败', + title: '智能体调用失败', + summary: error?.message || '无法连接后端 Orchestrator。', + agent: { + runId: '未生成', + selectedAgent: 'orchestrator', + scenario: '未知', + intent: '未知', + permissionLevel: 'unknown', + routeReason: 'request_failed', + requiresConfirmation: false, + degraded: false, + fileNames, + citations: [], + suggestedActions: [], + queryPayload: null, + draftPayload: null, + reviewPayload: null, + riskFlags: [], + toolCount: 0, + failedToolCount: 0, + selectedCapabilityCodes: [], + filePreviews: [], + statusLabel: '失败', + statusTone: 'note' + } + } +} + +export function buildAgentInsight(payload, fileNames = [], filePreviews = []) { + const trace = payload?.trace_summary || {} + const result = payload?.result || {} + const statusLabel = resolveStatusLabel(payload?.status) + + return { + intent: 'agent', + metricLabel: '运行状态', + metricValue: statusLabel, + title: + result?.draft_payload?.title || + `${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`, + summary: result?.answer || result?.message || '智能体已完成处理。', + agent: { + runId: payload?.run_id || '未生成', + selectedAgent: payload?.selected_agent || 'orchestrator', + scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知', + intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知', + permissionLevel: payload?.permission_level || 'unknown', + routeReason: payload?.route_reason || 'unknown', + requiresConfirmation: Boolean(payload?.requires_confirmation), + degraded: Boolean(trace?.degraded), + fileNames, + citations: Array.isArray(result?.citations) ? result.citations : [], + suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], + queryPayload: normalizeExpenseQueryPayload(result?.query_payload), + draftPayload: result?.draft_payload || null, + reviewPayload: result?.review_payload || null, + riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], + toolCount: Number(trace?.tool_count || 0), + failedToolCount: Number(trace?.failed_tool_count || 0), + selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) + ? trace.selected_capability_codes + : [], + filePreviews, + statusLabel, + statusTone: resolveStatusTone(payload?.status) + } + } +} diff --git a/web/src/views/scripts/travelReimbursementConversationModel.js b/web/src/views/scripts/travelReimbursementConversationModel.js new file mode 100644 index 0000000..a4e98a8 --- /dev/null +++ b/web/src/views/scripts/travelReimbursementConversationModel.js @@ -0,0 +1,767 @@ +import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js' +import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' +import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js' +import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js' + +export const SESSION_TYPE_EXPENSE = 'expense' +export const SESSION_TYPE_KNOWLEDGE = 'knowledge' + +export const aiAvatar = '/assets/header.png' +export const userAvatar = '/assets/person.png' + +export const SOURCE_LABELS = { + workbench: '来自个人工作台', + topbar: '来自发起报销', + detail: '来自智能录入', + upload: '来自附件上传', + requests: '来自报销列表' +} + +export const SCENARIO_LABELS = { + expense: '报销', + accounts_receivable: '应收', + accounts_payable: '应付', + knowledge: '知识', + unknown: '通用' +} + +export const INTENT_LABELS = { + query: '查询', + explain: '解释', + compare: '对比', + risk_check: '风险检查', + draft: '信息核对', + operate: '动作请求' +} + +export const FLOW_STEP_FALLBACKS = { + intent: { + title: '意图识别', + tool: 'IntentRecognizer', + runningText: '正在识别业务意图...', + completedText: '意图识别完成' + }, + extraction: { + title: '信息提取', + tool: 'SemanticExtractor', + runningText: '正在提取时间、金额、费用类型和待补项...', + completedText: '信息提取完成' + }, + ocr: { + title: '票据/OCR识别', + tool: 'OCRService', + runningText: '正在识别票据附件...', + completedText: '票据识别完成' + }, + 'expense-review-preview': { + title: '报销信息核对', + tool: 'user_agent.expense_review_preview', + runningText: '正在整理识别结果和右侧核对信息...', + completedText: '核对信息已整理' + }, + 'expense-claim-draft': { + title: '保存报销草稿', + tool: 'database.expense_claims.save_or_submit', + runningText: '正在把已确认信息保存为草稿...', + completedText: '草稿已保存' + }, + 'expense-scene-selection': { + title: '报销场景确认', + tool: 'UserConfirmation', + runningText: '等待用户选择报销场景...', + completedText: '已进入场景选择,等待用户确认' + }, + 'expense-intent-confirmation': { + title: '报销意图确认', + tool: 'UserConfirmation', + runningText: '等待用户确认是否发起报销...', + completedText: '用户已确认报销意图' + } +} +export const ASSISTANT_DISPLAY_NAME = '财务助手' + +export const EXPENSE_WELCOME_QUICK_ACTIONS = [ + { + label: '发起差旅报销', + prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。', + icon: 'mdi mdi-bag-suitcase-outline' + }, + { + label: '招待费报销', + prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。', + icon: 'mdi mdi-food-fork-drink' + }, + { + label: '交通费报销', + prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。', + icon: 'mdi mdi-car-outline' + }, + { + label: '上传票据识别', + prompt: '我已准备好票据,请帮我识别并整理报销核对信息。', + icon: 'mdi mdi-file-upload-outline' + }, + { + label: '查询近期报销', + prompt: '帮我查询近10天的报销记录和金额汇总。', + icon: 'mdi mdi-chart-timeline-variant' + }, + { + label: '解释报销风险', + prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。', + icon: 'mdi mdi-shield-alert-outline' + } +] + +export const HOT_KNOWLEDGE_QUESTIONS = [ + '差旅住宿标准按什么规则执行?', + '酒店超标后如何申请例外报销?', + '招待费报销需要哪些凭证?', + '发票抬头不一致还能报销吗?', + '电子发票验真失败怎么处理?', + '借款多久内需要冲销?', + '预算不足还能先提交报销吗?', + '会议费和招待费如何区分?', + '跨部门项目费用应该怎么归集?', + '员工退票手续费是否可以报销?' +] +export const FLOW_MISSING_SLOT_LABELS = { + expense_type: '报销类型', + customer_name: '客户名称', + time_range: '发生时间', + location: '地点', + merchant_name: '酒店/商户', + amount: '金额', + reason: '事由说明', + participants: '参与人员', + attachments: '票据附件' +} + +let messageSeed = 0 + +export function nowTime() { + return new Date().toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) +} + +export function createMessage(role, text, attachments = [], extras = {}) { + messageSeed += 1 + return { + id: `msg-${messageSeed}`, + role, + text, + attachments, + time: nowTime(), + meta: [], + citations: [], + suggestedActions: [], + suggestedActionsLocked: false, + selectedSuggestedActionKey: '', + selectedSuggestedActionLabel: '', + querySelectionLocked: false, + selectedQueryRecordId: '', + queryPayload: null, + draftPayload: null, + reviewPayload: null, + riskFlags: [], + ...extras + } +} + +export function buildExpenseIntentConfirmationMessage(rawText) { + const text = String(rawText || '').trim() + return [ + text + ? `我看到了「${text}」这类业务事项描述。` + : '我看到了这类业务事项描述。', + '但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。', + '如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。' + ].join('\n') +} + +export function buildExpenseSceneSelectionMessage(rawText) { + const text = String(rawText || '').trim() + const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text) + const prefix = hasBusinessTime + ? '我已看到你提供了业务发生时间和报销意图。' + : '我已识别到这是报销申请。' + + return [ + `${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取。`, + '请先选择本次要发起的报销场景,选择后我再按对应规则继续识别并整理核对信息。' + ].join('\n') +} + +export function formatMessageTime(value) { + if (!value) { + return nowTime() + } + + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + return nowTime() + } + + return parsed.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) +} + +export function formatSemanticEntityValue(entity) { + const normalizedValue = String(entity?.normalized_value || '').trim() + const rawValue = String(entity?.value || '').trim() + const entityType = String(entity?.type || '').trim() + + if (entityType === 'amount') { + const numericValue = Number(normalizedValue || rawValue) + if (Number.isFinite(numericValue) && numericValue > 0) { + return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` + } + } + + return rawValue || normalizedValue +} + +export function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) { + if (!semanticParse || typeof semanticParse !== 'object') { + return FLOW_STEP_FALLBACKS.extraction.completedText + } + + const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : [] + const entityMap = new Map() + for (const item of entities) { + const entityType = String(item?.type || '').trim() + if (!entityType || entityMap.has(entityType)) continue + entityMap.set(entityType, item) + } + + const extractedParts = [] + const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object' + ? semanticParse.time_range_json + : {} + const startDate = String(timeRange.start_date || '').trim() + const endDate = String(timeRange.end_date || '').trim() + if (startDate) { + extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`) + } + + const amountEntity = entityMap.get('amount') + if (amountEntity) { + const amountValue = formatSemanticEntityValue(amountEntity) + if (amountValue) { + extractedParts.push(`金额 ${amountValue}`) + } + } + + const expenseTypeEntity = entityMap.get('expense_type') + if (expenseTypeEntity) { + const expenseTypeLabel = resolveExpenseTypeLabel( + String(expenseTypeEntity?.normalized_value || '').trim(), + String(expenseTypeEntity?.value || '').trim() + ) + if (expenseTypeLabel) { + extractedParts.push(`费用类型 ${expenseTypeLabel}`) + } + } + + const customerEntity = entityMap.get('customer') + if (customerEntity) { + const customerValue = formatSemanticEntityValue(customerEntity) + if (customerValue) { + extractedParts.push(`客户 ${customerValue}`) + } + } + + const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : [] + const missingLabels = missingSlots + .map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()) + .filter(Boolean) + + if (extractedParts.length && missingLabels.length) { + return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}` + } + if (extractedParts.length) { + return `已提取${extractedParts.join('、')}` + } + if (missingLabels.length) { + return `已完成信息提取;待补充 ${missingLabels.join('、')}` + } + return FLOW_STEP_FALLBACKS.extraction.completedText +} + +export function sanitizeRequest(request) { + if (!request || typeof request !== 'object') return null + + const normalized = { + id: String(request.id || '').trim(), + typeLabel: String(request.typeLabel || request.category || '').trim(), + reason: String(request.reason || request.title || '').trim(), + entity: String(request.entity || '').trim(), + city: String(request.city || request.location || '').trim(), + period: String(request.period || '').trim(), + applyTime: String(request.applyTime || request.occurredAt || '').trim(), + amount: String(request.amount || '').trim(), + node: String(request.node || '').trim(), + approval: String(request.approval || '').trim(), + travel: String(request.travel || '').trim() + } + + return Object.values(normalized).some(Boolean) ? normalized : null +} + +export function resolveStatusLabel(status) { + if (status === 'succeeded') return '已完成' + if (status === 'blocked') return '已阻断' + return '失败' +} + +export function resolveStatusTone(status) { + if (status === 'succeeded') return 'success' + if (status === 'blocked') return 'warning' + return 'note' +} + +export function buildMessageMeta(payload, fileNames = []) { + const items = [] + + if (payload?.selected_agent) { + items.push(`Agent: ${payload.selected_agent}`) + } + + if (payload?.permission_level) { + items.push(`权限: ${payload.permission_level}`) + } + + if (payload?.trace_summary?.tool_count) { + items.push(`工具: ${payload.trace_summary.tool_count}`) + } + + if (payload?.trace_summary?.degraded) { + items.push('已降级') + } + + if (payload?.requires_confirmation) { + items.push('待确认') + } + + if (payload?.run_id) { + items.push(`Run: ${payload.run_id}`) + } + + if (fileNames.length) { + items.push(`附件: ${fileNames.length}`) + } + + return items +} + +export function buildStoredMessageMeta(messageJson, attachmentNames = []) { + const payload = messageJson?.orchestrator_payload + if (payload) { + return buildMessageMeta(payload, attachmentNames) + } + + const items = [] + if (messageJson?.status) { + items.push(`状态: ${messageJson.status}`) + } + if (attachmentNames.length) { + items.push(`附件: ${attachmentNames.length}`) + } + return items +} + +export function buildWelcomeUserContext(user = {}) { + const username = String(user.username || '').trim() + const name = String(user.name || username || '同事').trim() + const grade = String(user.grade || '').trim() + const position = String(user.position || '').trim() + const role = String(user.role || '').trim() + const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : [] + const isAdmin = + Boolean(user.isAdmin) + || username.toLowerCase() === 'admin' + || roleCodes.some((item) => /admin|manager/i.test(String(item || ''))) + || /管理员|系统管理/.test(position) + || /管理员|系统管理/.test(role) + + const now = new Date() + const dateLine = now.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }) + + let honorific = name + if (isAdmin) { + honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员' + } else { + const prefix = [grade, position].filter(Boolean).join(' ') + honorific = prefix ? `${prefix} ${name}`.trim() : name + } + + return { + name, + username, + grade, + position, + role, + isAdmin, + honorific, + dateLine + } +} + +export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) { + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({ + label: question.length > 20 ? `${question.slice(0, 20)}…` : question, + prompt: question, + icon: 'mdi mdi-comment-question-outline' + })) + } + + if (entrySource === 'detail' && linkedRequest?.id) { + return [ + { + label: '补充当前单据票据', + prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`, + icon: 'mdi mdi-file-plus-outline' + }, + { + label: '解释本单风险', + prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`, + icon: 'mdi mdi-shield-alert-outline' + }, + ...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4) + ] + } + + return EXPENSE_WELCOME_QUICK_ACTIONS +} + +export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { + const ctx = buildWelcomeUserContext(user || {}) + const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}` + + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return [ + `${greeting}!今日是 **${ctx.dateLine}**。`, + '', + '欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。', + '', + '您可以直接输入问题,或点击下方「猜你想问」快速开始。' + ].join('\n') + } + + if (entrySource === 'detail' && linkedRequest?.id) { + return [ + `${greeting}!今日是 **${ctx.dateLine}**。`, + '', + `我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`, + '', + '如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。' + ].join('\n') + } + + return [ + `${greeting}!今日是 **${ctx.dateLine}**。`, + '', + '**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销信息核对、待补项提醒和风险说明。', + '', + '您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。' + ].join('\n') +} + +export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { + const ctx = buildWelcomeUserContext(user || {}) + + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return { + intent: 'welcome', + metricLabel: '今日', + metricValue: ctx.dateLine.split(' ')[0] || '—', + title: '财务知识问答', + summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`, + agent: null + } + } + + return { + intent: 'welcome', + metricLabel: '助手状态', + metricValue: '待您吩咐', + title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心', + summary: + entrySource === 'detail' && linkedRequest?.id + ? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。` + : `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`, + agent: null + } +} + +export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { + return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], { + assistantName: ASSISTANT_DISPLAY_NAME, + isWelcome: true, + welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) + }) +} + +export function resolveInitialSessionType(conversation) { + const stateJson = conversation?.state_json || conversation?.stateJson || {} + const sessionType = String(stateJson?.session_type || '').trim() + return sessionType || SESSION_TYPE_EXPENSE +} + +export function buildInitialInsightFromConversation(conversation) { + const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] + for (let index = rawMessages.length - 1; index >= 0; index -= 1) { + const item = rawMessages[index] + const messageJson = item?.message_json || item?.messageJson || {} + const orchestratorPayload = messageJson?.orchestrator_payload || null + if (!orchestratorPayload) continue + const attachmentNames = Array.isArray(messageJson?.attachment_names) + ? messageJson.attachment_names.filter(Boolean) + : [] + return buildAgentInsight( + orchestratorPayload, + attachmentNames, + buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload) + ) + } + return null +} + +export function resolveInitialConversationId(conversation) { + return String(conversation?.conversation_id || conversation?.conversationId || '').trim() +} + +export function resolveInitialDraftClaimId(conversation) { + return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() +} + +export function resolveKnowledgeRankLabel(index) { + return String(index + 1) +} + +export function resolveKnowledgeRankTone(index) { + if (index === 0) return 'gold' + if (index === 1) return 'silver' + if (index === 2) return 'bronze' + return 'default' +} + +export function parseConversationMessageSequence(message) { + const messageJson = message?.message_json || message?.messageJson || {} + const sequence = Number.parseInt(messageJson?.sequence, 10) + return Number.isFinite(sequence) && sequence > 0 ? sequence : null +} + +export function parseConversationMessageTime(message) { + const rawValue = message?.created_at || message?.createdAt || '' + const timestamp = new Date(rawValue).getTime() + return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER +} + +export function resolveConversationMessageRolePriority(message) { + return String(message?.role || '').trim() === 'user' ? 0 : 1 +} + +export function sortConversationMessages(messages) { + return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => { + const leftSequence = parseConversationMessageSequence(left) + const rightSequence = parseConversationMessageSequence(right) + if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) { + return leftSequence - rightSequence + } + + const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right) + if (timeDiff !== 0) { + return timeDiff + } + + const leftRunId = String(left?.run_id || left?.runId || '').trim() + const rightRunId = String(right?.run_id || right?.runId || '').trim() + if (leftRunId && rightRunId && leftRunId === rightRunId) { + const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right) + if (roleDiff !== 0) { + return roleDiff + } + } + + return String(left?.id || '').localeCompare(String(right?.id || '')) + }) +} + +export function normalizeInitialConversationMessages(conversation) { + const rawMessages = sortConversationMessages(conversation?.messages) + + const restoredMessages = rawMessages.map((item) => { + const messageJson = item?.message_json || item?.messageJson || {} + const attachmentNames = Array.isArray(messageJson?.attachment_names) + ? messageJson.attachment_names.filter(Boolean) + : [] + const orchestratorPayload = messageJson?.orchestrator_payload || null + const result = orchestratorPayload?.result || {} + + return createMessage(item.role, item.content, attachmentNames, { + id: `restored-${item.id || ++messageSeed}`, + time: formatMessageTime(item.created_at || item.createdAt), + meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [], + citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [], + suggestedActions: + item.role === 'assistant' && Array.isArray(result?.suggested_actions) + ? result.suggested_actions + : [], + queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, + draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, + reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, + riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] + }) + }) + return markResolvedSuggestedActionMessages(restoredMessages) +} + +export function normalizeSnapshotMessage(message) { + const extras = message && typeof message === 'object' ? { ...message } : {} + const role = String(extras.role || 'assistant').trim() || 'assistant' + const text = String(extras.text || '') + const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : [] + delete extras.role + delete extras.text + delete extras.attachments + return createMessage(role, text, attachments, extras) +} + +export function normalizeSnapshotMessages(messages) { + return Array.isArray(messages) + ? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage)) + : [] +} + +export function serializeSessionMessages(messages) { + return (Array.isArray(messages) ? messages : []).map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [], + time: message.time, + meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [], + metaTone: message.metaTone || '', + citations: Array.isArray(message.citations) ? message.citations : [], + suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], + suggestedActionsLocked: Boolean(message.suggestedActionsLocked), + selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''), + selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''), + querySelectionLocked: Boolean(message.querySelectionLocked), + selectedQueryRecordId: String(message.selectedQueryRecordId || ''), + queryPayload: message.queryPayload || null, + draftPayload: message.draftPayload || null, + reviewPayload: message.reviewPayload || null, + riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [], + assistantName: message.assistantName || '', + isWelcome: Boolean(message.isWelcome), + welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : [] + })) +} + +export function hasMeaningfulSessionMessages(messages) { + return (Array.isArray(messages) ? messages : []).some((message) => { + if (!message || message.isWelcome) { + return false + } + if (message.role === 'user') { + return true + } + return Boolean( + String(message.text || '').trim() + || (Array.isArray(message.suggestedActions) && message.suggestedActions.length) + || message.reviewPayload + || message.queryPayload + || message.draftPayload + ) + }) +} + +export function hasActiveSuggestedActionMessage(messages) { + return (Array.isArray(messages) ? messages : []).some( + (message) => + message?.role === 'assistant' + && Array.isArray(message.suggestedActions) + && message.suggestedActions.length > 0 + && !message.suggestedActionsLocked + ) +} + +export function resolveConversationUpdatedAt(conversation) { + const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime() + return Number.isFinite(timestamp) ? timestamp : 0 +} + +export function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) { + if (!persistedState) { + return false + } + if (!conversation) { + return true + } + if (hasActiveSuggestedActionMessage(persistedState.messages)) { + return true + } + const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0) + return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation) +} + +export function markResolvedSuggestedActionMessages(messages) { + const items = Array.isArray(messages) ? messages : [] + const selectedLabels = new Set() + + for (const message of items) { + if (message?.role !== 'user') { + continue + } + const text = String(message.text || '').trim() + const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[::]\s*([^\n\r]+)/) + if (selectedMatch?.[1]) { + selectedLabels.add(selectedMatch[1].trim()) + } else if (text === '我要报销') { + selectedLabels.add(text) + } + } + + if (!selectedLabels.size) { + return items + } + + return items.map((message) => { + if ( + message?.role !== 'assistant' + || message.suggestedActionsLocked + || !Array.isArray(message.suggestedActions) + || !message.suggestedActions.length + ) { + return message + } + + const selectedAction = message.suggestedActions.find((action) => + selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim()) + ) + if (!selectedAction) { + return message + } + + return { + ...message, + suggestedActionsLocked: true, + selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction), + selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim() + } + }) +} diff --git a/web/src/views/scripts/travelReimbursementExpenseQueryModel.js b/web/src/views/scripts/travelReimbursementExpenseQueryModel.js new file mode 100644 index 0000000..3d679fa --- /dev/null +++ b/web/src/views/scripts/travelReimbursementExpenseQueryModel.js @@ -0,0 +1,268 @@ +import { + EXPENSE_TYPE_LABELS, + formatAmountDisplay +} from './travelReimbursementReviewModel.js' + +export const EXPENSE_QUERY_PAGE_SIZE = 5 +export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned']) +const EXPENSE_STATUS_LABELS = { + draft: '草稿', + supplement: '待补充', + returned: '已退回', + submitted: '已提交', + review: '审批中', + approved: '已审核', + paid: '已入账' +} + +export function normalizeExpenseQueryStatusGroup(item) { + if (!item || typeof item !== 'object') { + return null + } + + const rawCount = Number(item.count || 0) + return { + key: String(item.key || 'other').trim() || 'other', + label: String(item.label || '其他状态').trim() || '其他状态', + count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0 + } +} + +export function normalizeExpenseQueryRecord(item) { + if (!item || typeof item !== 'object') { + return null + } + + const amount = Number(item.amount || 0) + const amountValue = Number.isFinite(amount) ? amount : 0 + const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销' + const reason = String(item.reason || '').trim() + const documentDate = String(item.document_date || '').trim() + const occurredAt = String(item.occurred_at || '').trim() + + return { + claimId: String(item.claim_id || '').trim(), + claimNo: String(item.claim_no || '').trim() || '未编号', + employeeName: String(item.employee_name || '').trim(), + expenseType: String(item.expense_type || '').trim(), + expenseTypeLabel, + amount: amountValue, + amountDisplay: formatAmountDisplay(amountValue), + status: String(item.status || '').trim(), + statusLabel: String(item.status_label || '处理中').trim() || '处理中', + statusGroup: String(item.status_group || 'other').trim() || 'other', + statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态', + approvalStage: String(item.approval_stage || '').trim(), + documentDate, + occurredAt, + reason, + location: String(item.location || '').trim(), + summary: reason || `${expenseTypeLabel}报销`, + dateDisplay: documentDate || occurredAt || '待补充日期' + } +} + +export function resolveExpenseStatusGroup(status) { + const normalized = String(status || '').trim() + if (['draft', 'supplement', 'returned'].includes(normalized)) { + return { key: 'draft', label: normalized === 'draft' ? '草稿' : '待完善' } + } + if (['submitted', 'review'].includes(normalized)) { + return { key: 'in_progress', label: '审批中' } + } + if (['approved', 'paid'].includes(normalized)) { + return { key: 'completed', label: '已完成' } + } + return { key: 'other', label: '其他状态' } +} + +export function formatQueryRecordDate(value) { + const text = String(value || '').trim() + if (!text) return '' + return text.includes('T') ? text.split('T')[0] : text.slice(0, 10) +} + +export function buildQueryRecordFromClaim(claim) { + if (!claim || typeof claim !== 'object') { + return null + } + const claimId = String(claim.id || claim.claim_id || '').trim() + if (!claimId) { + return null + } + + const status = String(claim.status || '').trim() + const statusGroup = resolveExpenseStatusGroup(status) + return { + claim_id: claimId, + claim_no: String(claim.claim_no || claim.claimNo || '').trim() || '未编号', + employee_name: String(claim.employee_name || claim.employeeName || '').trim(), + expense_type: String(claim.expense_type || claim.expenseType || '').trim(), + expense_type_label: EXPENSE_TYPE_LABELS[String(claim.expense_type || claim.expenseType || '').trim()] || String(claim.expense_type || claim.expenseType || '报销').trim(), + amount: Number(claim.amount || 0), + status, + status_label: EXPENSE_STATUS_LABELS[status] || statusGroup.label, + status_group: statusGroup.key, + status_group_label: statusGroup.label, + approval_stage: String(claim.approval_stage || claim.approvalStage || '').trim(), + document_date: formatQueryRecordDate(claim.submitted_at || claim.submittedAt || claim.created_at || claim.createdAt || claim.occurred_at || claim.occurredAt), + occurred_at: formatQueryRecordDate(claim.occurred_at || claim.occurredAt), + reason: String(claim.reason || '').trim(), + location: String(claim.location || '').trim() + } +} + +export function buildDraftAssociationQueryPayload(claims) { + const records = (Array.isArray(claims) ? claims : []) + .filter((claim) => ASSOCIATABLE_CLAIM_STATUSES.has(String(claim?.status || '').trim())) + .map(buildQueryRecordFromClaim) + .filter(Boolean) + + const statusGroups = records.reduce((groups, record) => { + const key = String(record.status_group || 'other') + const existing = groups.get(key) || { + key, + label: String(record.status_group_label || '其他状态'), + count: 0 + } + existing.count += 1 + groups.set(key, existing) + return groups + }, new Map()) + + return normalizeExpenseQueryPayload({ + result_type: 'expense_claim_list', + title: '选择关联草稿', + scope_label: '可关联草稿', + selection_mode: 'draft_association', + empty_text: '当前没有可关联的草稿单据。', + recent_window_applied: false, + record_count: records.length, + preview_count: records.length, + total_amount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0), + status_groups: Array.from(statusGroups.values()), + records + }) +} + +export function normalizeExpenseQueryPayload(payload) { + if (!payload || typeof payload !== 'object') { + return null + } + + const resultType = String(payload.result_type || '').trim() + if (resultType && resultType !== 'expense_claim_list') { + return null + } + + const records = (Array.isArray(payload.records) ? payload.records : []) + .map(normalizeExpenseQueryRecord) + .filter(Boolean) + const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : []) + .map(normalizeExpenseQueryStatusGroup) + .filter(Boolean) + + const rawRecordCount = Number(payload.record_count || 0) + const rawPreviewCount = Number(payload.preview_count || records.length) + const rawOlderRecordCount = Number(payload.older_record_count || 0) + const totalAmount = Number(payload.total_amount || 0) + const rawWindowDays = Number(payload.window_days || 0) + const windowStartDate = String(payload.window_start_date || '').trim() + const windowEndDate = String(payload.window_end_date || '').trim() + + return { + resultType: 'expense_claim_list', + scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单', + selectionMode: String(payload.selection_mode || payload.selectionMode || '').trim(), + selectionLocked: Boolean(payload.selection_locked || payload.selectionLocked), + selectedClaimId: String(payload.selected_claim_id || payload.selectedClaimId || '').trim(), + title: String(payload.title || '').trim(), + emptyText: String(payload.empty_text || payload.emptyText || '').trim(), + recentWindowApplied: Boolean(payload.recent_window_applied), + windowDays: + payload.window_days === null || payload.window_days === undefined || payload.window_days === '' + ? null + : (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null), + windowStartDate: windowStartDate || '', + windowEndDate: windowEndDate || '', + recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0, + previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length, + olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0, + hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more), + totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0, + statusGroups, + records, + currentPage: 1 + } +} + +export function buildExpenseQueryWindowLabel(queryPayload) { + if (!queryPayload) { + return '' + } + + if (queryPayload.selectionMode === 'draft_association') { + return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。' + } + + if (queryPayload.windowStartDate && queryPayload.windowEndDate) { + return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}` + } + + if (queryPayload.recentWindowApplied && queryPayload.windowDays) { + return `近 ${queryPayload.windowDays} 日内` + } + + return '当前条件下' +} + +export function getExpenseQueryTotalPages(queryPayload) { + const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0 + return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE)) +} + +export function getExpenseQueryActivePage(queryPayload) { + const totalPages = getExpenseQueryTotalPages(queryPayload) + const rawPage = Number(queryPayload?.currentPage || 1) + if (!Number.isFinite(rawPage)) { + return 1 + } + return Math.min(Math.max(1, Math.round(rawPage)), totalPages) +} + +export function getExpenseQueryVisibleRecords(queryPayload) { + const records = Array.isArray(queryPayload?.records) ? queryPayload.records : [] + const activePage = getExpenseQueryActivePage(queryPayload) + const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE + return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE) +} + +export function buildExpenseQueryHint(queryPayload) { + if (!queryPayload) { + return '' + } + + if (queryPayload.selectionMode === 'draft_association') { + if (queryPayload.selectionLocked && queryPayload.selectedClaimId) { + return '已选择关联草稿,附件将按该单据继续识别和归集。' + } + return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。' + } + + const parts = [] + const windowText = buildExpenseQueryWindowLabel(queryPayload) + + if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) { + parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`) + } + + if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) { + parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`) + } + + if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) { + parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`) + } + + return parts.join('。') +} diff --git a/web/src/views/scripts/travelReimbursementReviewConstants.js b/web/src/views/scripts/travelReimbursementReviewConstants.js new file mode 100644 index 0000000..83db151 --- /dev/null +++ b/web/src/views/scripts/travelReimbursementReviewConstants.js @@ -0,0 +1,149 @@ +import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js' + +export const DOCUMENT_TYPE_LABELS = { + travel_ticket: '行程单/机票/车票', + flight_itinerary: '机票/航班行程单', + train_ticket: '火车/高铁票', + hotel_invoice: '酒店住宿票据', + taxi_receipt: '出租车/网约车票据', + parking_toll_receipt: '停车/通行费票据', + transport_receipt: '交通出行票据', + meal_receipt: '餐饮票据', + office_invoice: '办公用品票据', + meeting_invoice: '会议/会务票据', + training_invoice: '培训票据', + vat_invoice: '增值税发票', + receipt: '一般收据/凭证', + other: '其他单据' +} + +export const EXPENSE_TYPE_LABELS = { + travel: '差旅费', + hotel: '住宿费', + transport: '交通费', + meal: '伙食费', + meeting: '会务费', + entertainment: '业务招待费', + office: '办公费', + training: '培训费', + communication: '通讯费', + welfare: '福利费', + other: '其他费用' +} + +export const REVIEW_SLOT_CONFIG = { + expense_type: { + title: '报销分类', + hint: '请选择本次报销分类', + status: '待确认', + icon: 'mdi mdi-shape-outline' + }, + customer_name: { + title: '关联客户', + hint: '请补充客户单位全称', + status: '待补充', + icon: 'mdi mdi-domain' + }, + time_range: { + title: '发生时间', + hint: '请按 YYYY-MM-DD 补充业务发生日期', + status: '待补充', + icon: 'mdi mdi-calendar-month-outline' + }, + location: { + title: '业务地点', + hint: '请补充业务发生地点', + status: '待补充', + icon: 'mdi mdi-map-marker-outline' + }, + merchant_name: { + title: '酒店/商户', + hint: '请补充酒店或商户名称', + status: '待补充', + icon: 'mdi mdi-storefront-outline' + }, + amount: { + title: '金额', + hint: '请补充本次费用金额', + status: '待补充', + icon: 'mdi mdi-cash' + }, + reason: { + title: '场景 / 事由', + hint: '请补充本次费用场景或事由', + status: '待补充', + icon: 'mdi mdi-text-box-outline' + }, + participants: { + title: '同行人员', + hint: '请至少填写 1 名同行人员', + status: '待补充', + icon: 'mdi mdi-account-group-outline' + }, + attachments: { + title: '票据状态', + hint: '请上传发票/收据等票据附件', + status: '未上传', + icon: 'mdi mdi-paperclip' + } +} + +export const REVIEW_FALLBACK_GROUP_CODES = [ + 'other', + 'travel', + 'transport', + 'hotel', + 'meal', + 'meeting', + 'entertainment', + 'office', + 'training', + 'communication', + 'welfare' +] + +export const REVIEW_CATEGORY_PRESET_OPTIONS = [ + { key: 'travel', label: '差旅费' }, + { key: 'transport', label: '交通费' }, + { key: 'hotel', label: '住宿费' }, + { key: 'meal', label: '餐费' }, + { key: 'entertainment', label: '业务招待费' }, + { key: 'other_trigger', label: '其他类型', is_other: true } +] + +export const REVIEW_OTHER_CATEGORY_OPTIONS = [ + { key: 'meeting', label: '会务费' }, + { key: 'office', label: '办公费' }, + { key: 'training', label: '培训费' }, + { key: 'communication', label: '通讯费' }, + { key: 'welfare', label: '福利费' }, + { key: 'other', label: '其他费用' } +] + +export const REVIEW_SCENE_OTHER_OPTION = '其他场景' + +export const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION] + +export const EXPENSE_CODE_TO_PRESET_SCENE = { + travel: '出差行程', + hotel: '住宿报销', + transport: '交通出行', + meeting: '会务活动', + entertainment: '请客户吃饭', + meal: '请客户吃饭' +} + +export const DATE_INPUT_FORMAT = 'YYYY-MM-DD' + +export const CATEGORY_CONFIDENCE_KEYWORDS = { + travel: [/出差|差旅|行程|机票|火车|高铁|航班/], + hotel: [/住宿|酒店|宾馆|民宿/], + transport: [TRANSPORT_KEYWORD_PATTERN], + meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/], + meeting: [/会务|会议|论坛|展会|参会|会场/], + entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/], + office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/], + training: [/培训|授课|讲师|课程|签到|讲义/], + communication: [/通讯|电话|流量|话费|宽带|网络/], + welfare: [/福利|体检|团建|节日|慰问|关怀/] +} diff --git a/web/src/views/scripts/travelReimbursementReviewDocuments.js b/web/src/views/scripts/travelReimbursementReviewDocuments.js new file mode 100644 index 0000000..3321edb --- /dev/null +++ b/web/src/views/scripts/travelReimbursementReviewDocuments.js @@ -0,0 +1,155 @@ +import { + DOCUMENT_TYPE_LABELS, + EXPENSE_TYPE_LABELS +} from './travelReimbursementReviewConstants.js' + +export function cloneReviewDocumentDrafts(items) { + return (Array.isArray(items) ? items : []).map((item) => ({ + ...item, + warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [], + fields: Array.isArray(item?.fields) + ? item.fields.map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || ''), + source: String(field?.source || 'ocr').trim() || 'ocr' + })) + : [] + })) +} + +export function buildReviewDocumentDrafts(reviewPayload) { + return buildReviewDocumentSummaries(reviewPayload).map((item) => ({ + index: Number(item.index || 0), + filename: String(item.filename || '').trim(), + document_type: String(item.document_type || 'other').trim() || 'other', + suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other', + scene_label: String(item.scene_label || '').trim(), + summary: String(item.summary || '').trim(), + confidenceLabel: String(item.confidenceLabel || '').trim(), + documentTypeLabel: String(item.documentTypeLabel || '').trim(), + expenseTypeLabel: String(item.expenseTypeLabel || '').trim(), + preview_kind: String(item.preview_kind || '').trim(), + preview_data_url: String(item.preview_data_url || '').trim(), + warnings: Array.isArray(item.warnings) ? [...item.warnings] : [], + fields: Array.isArray(item.fields) + ? item.fields.map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || ''), + source: String(field?.source || 'ocr').trim() || 'ocr' + })) + : [] + })) +} + +export function normalizeReviewDocumentComparableValue(item) { + return { + index: Number(item?.index || 0), + filename: String(item?.filename || '').trim(), + scene_label: String(item?.scene_label || '').trim(), + summary: String(item?.summary || '').trim(), + fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || '').trim() + })) + } +} + +export function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) { + const baseMap = new Map( + cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item]) + ) + + return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => { + const key = `${item.index}:${item.filename}` + const base = baseMap.get(key) + const changes = [] + const nextSceneLabel = String(item.scene_label || '').trim() + const baseSceneLabel = String(base?.scene_label || '').trim() + const nextSummary = String(item.summary || '').trim() + const baseSummary = String(base?.summary || '').trim() + + if (nextSceneLabel !== baseSceneLabel) { + changes.push(`票据场景:${nextSceneLabel || '待补充'}`) + } + + if (nextSummary !== baseSummary) { + changes.push(`识别摘要:${nextSummary || '待补充'}`) + } + + const baseFieldMap = new Map( + (Array.isArray(base?.fields) ? base.fields : []).map((field) => [ + String(field?.label || '').trim(), + String(field?.value || '').trim() + ]) + ) + + for (const field of Array.isArray(item.fields) ? item.fields : []) { + const label = String(field?.label || '').trim() + if (!label) continue + const nextValue = String(field?.value || '').trim() + const baseValue = baseFieldMap.get(label) || '' + if (nextValue !== baseValue) { + changes.push(`${label}:${nextValue || '待补充'}`) + } + } + + if (changes.length) { + lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`) + } + + return lines + }, []) +} + +export function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) { + const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + if (!lines.length) { + return '' + } + + return `请同步修正逐票据识别结果:\n${lines.join('\n')}` +} + +export function buildReviewDocumentCorrectionContext(drafts) { + return cloneReviewDocumentDrafts(drafts).map((item) => ({ + index: item.index, + filename: item.filename, + scene_label: String(item.scene_label || '').trim(), + summary: String(item.summary || '').trim(), + fields: item.fields.map((field) => ({ + label: String(field.label || '').trim(), + value: String(field.value || '').trim() + })) + })) +} + +export function formatConfidenceLabel(value) { + const score = Number(value || 0) + if (!score) return '待补充' + return `${Math.round(score * 100)}%` +} + +export function resolveDocumentTypeLabel(type) { + return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other +} + +export function resolveExpenseTypeLabel(type, fallbackLabel = '') { + const normalized = String(type || '').trim() + return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other +} + +export function buildReviewDocumentSummaries(reviewPayload) { + const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + return docs.map((item) => { + const fields = Array.isArray(item.fields) ? item.fields : [] + return { + ...item, + documentTypeLabel: resolveDocumentTypeLabel(item.document_type), + expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label), + confidenceLabel: formatConfidenceLabel(item.avg_score), + lines: fields + .filter((field) => String(field?.value || '').trim()) + .map((field) => `${field.label}:${field.value}`) + } + }) +} diff --git a/web/src/views/scripts/travelReimbursementReviewModel.js b/web/src/views/scripts/travelReimbursementReviewModel.js new file mode 100644 index 0000000..394c334 --- /dev/null +++ b/web/src/views/scripts/travelReimbursementReviewModel.js @@ -0,0 +1,1463 @@ +import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js' +import { + CATEGORY_CONFIDENCE_KEYWORDS, + EXPENSE_CODE_TO_PRESET_SCENE, + EXPENSE_TYPE_LABELS, + REVIEW_CATEGORY_PRESET_OPTIONS, + REVIEW_SCENE_OPTIONS, + REVIEW_SCENE_OTHER_OPTION, + REVIEW_SLOT_CONFIG +} from './travelReimbursementReviewConstants.js' +import { + buildReviewDocumentCorrectionLines, + formatConfidenceLabel, + resolveExpenseTypeLabel +} from './travelReimbursementReviewDocuments.js' + +export { + CATEGORY_CONFIDENCE_KEYWORDS, + DATE_INPUT_FORMAT, + DOCUMENT_TYPE_LABELS, + EXPENSE_CODE_TO_PRESET_SCENE, + EXPENSE_TYPE_LABELS, + REVIEW_CATEGORY_PRESET_OPTIONS, + REVIEW_FALLBACK_GROUP_CODES, + REVIEW_OTHER_CATEGORY_OPTIONS, + REVIEW_SCENE_OPTIONS, + REVIEW_SCENE_OTHER_OPTION, + REVIEW_SLOT_CONFIG +} from './travelReimbursementReviewConstants.js' + +export { + buildReviewDocumentCorrectionContext, + buildReviewDocumentCorrectionLines, + buildReviewDocumentCorrectionMessage, + buildReviewDocumentDrafts, + buildReviewDocumentSummaries, + cloneReviewDocumentDrafts, + formatConfidenceLabel, + normalizeReviewDocumentComparableValue, + resolveDocumentTypeLabel, + resolveExpenseTypeLabel +} from './travelReimbursementReviewDocuments.js' + +export function cloneReviewEditFields(fields) { + const items = Array.isArray(fields) ? fields : [] + return items.map((item) => ({ + key: String(item?.key || '').trim(), + label: String(item?.label || '').trim(), + value: String(item?.value || ''), + placeholder: String(item?.placeholder || ''), + required: Boolean(item?.required), + field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text', + group: String(item?.group || 'basic').trim() || 'basic' + })) +} + +export function buildReviewFormValues(fields) { + return cloneReviewEditFields(fields).reduce((result, item) => { + if (!item.key) { + return result + } + result[item.key] = String(item.value || '').trim() + return result + }, {}) +} + +export function buildBusinessTimeContextFromReviewValues(values = {}) { + const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim() + if (!timeText) { + return null + } + + const matchedDates = timeText.match(/\d{4}-\d{2}-\d{2}/g) || [] + if (!matchedDates.length) { + return null + } + const startDate = matchedDates[0] + const endDate = matchedDates[matchedDates.length - 1] || startDate + if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { + return null + } + const displayValue = startDate === endDate ? startDate : `${startDate} 至 ${endDate}` + return { + mode: startDate === endDate ? 'single' : 'range', + start_date: startDate, + end_date: endDate, + display_value: displayValue + } +} + +export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) { + if (!reviewPayload || typeof reviewPayload !== 'object') { + return {} + } + + const fallbackState = buildInlineReviewState(reviewPayload) + const candidateState = inlineState || fallbackState + const hasCandidateValue = Object.values(candidateState || {}).some((value) => { + if (typeof value === 'number') return value > 0 + return Boolean(String(value || '').trim()) + }) + const state = hasCandidateValue ? candidateState : fallbackState + const fields = mergeInlineReviewFields(reviewPayload.edit_fields || [], state) + const values = buildReviewFormValues(fields) + const slotMap = buildReviewSlotMap(reviewPayload) + const inheritedTimeRange = String( + slotMap.time_range?.normalized_value || + slotMap.time_range?.value || + values.time_range || + values.business_time || + values.occurred_date || + '' + ).trim() + if (inheritedTimeRange) { + values.time_range = values.time_range || inheritedTimeRange + values.business_time = values.business_time || inheritedTimeRange + } + + const businessTimeContext = buildBusinessTimeContextFromReviewValues(values) + return { + review_form_values: values, + ...(businessTimeContext ? { business_time_context: businessTimeContext } : {}) + } +} + +export function buildReviewEditFieldMap(fields) { + return cloneReviewEditFields(fields).reduce((result, item) => { + if (!item.key) return result + result[item.key] = item + return result + }, {}) +} + +export function createEmptyInlineReviewState() { + return { + occurred_date: '', + amount: '', + transport_type: '', + scene_label: '', + reason_value: '', + customer_name: '', + location: '', + merchant_name: '', + participants: '', + attachment_names: '', + attachment_count: 0, + pending_attachment_count: 0, + expense_type: '' + } +} + +export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const expenseType = resolveExpenseTypeCode( + inlineState?.expense_type || + buildReviewSlotMap(reviewPayload).expense_type?.normalized_value || + buildReviewSlotMap(reviewPayload).expense_type?.value || + '' + ) + if (['travel', 'hotel', 'transport'].includes(expenseType)) { + return true + } + + return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => { + const documentType = String(item?.document_type || '').trim().toLowerCase() + const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '') + return ( + ['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) || + ['travel', 'hotel', 'transport'].includes(suggestedType) + ) + }) +} + +export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const labels = [] + + const appendLabel = (label) => { + if (label && !labels.includes(label)) { + labels.push(label) + } + } + + for (const item of documents) { + const documentType = String(item?.document_type || '').trim().toLowerCase() + const text = [ + item?.filename, + item?.summary, + item?.scene_label, + item?.suggested_expense_type, + ...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : []) + ].join(' ') + const compact = text.replace(/\s+/g, '') + + if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) { + appendLabel('飞机') + } else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) { + appendLabel('火车/高铁') + } else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) { + appendLabel('打车/网约车') + } + } + + const fallback = String(fallbackText || '').replace(/\s+/g, '') + if (!labels.length) { + if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机') + if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁') + if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车') + } + + return labels.join('、') +} + +export function resolveReviewRecognizedSlotCards(reviewPayload) { + return Array.isArray(reviewPayload?.slot_cards) + ? reviewPayload.slot_cards.filter((item) => item.status !== 'missing') + : [] +} + +export function resolveReviewMissingSlotCards(reviewPayload) { + return Array.isArray(reviewPayload?.slot_cards) + ? reviewPayload.slot_cards.filter((item) => item.status === 'missing') + : [] +} + +export function resolveReviewExtraMissingLabels(reviewPayload) { + const labels = Array.isArray(reviewPayload?.missing_slots) + ? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean) + : [] + if (!labels.length) return [] + + const slotLabels = new Set( + (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []) + .map((item) => String(item?.label || item?.key || '').trim()) + .filter(Boolean) + ) + return labels.filter((label) => !slotLabels.has(label)) +} + +export function buildReviewRecognizedLines(reviewPayload) { + return resolveReviewRecognizedSlotCards(reviewPayload) + .filter((item) => String(item?.value || '').trim()) + .map((item) => `${item.label}:${item.value}`) +} + +export function buildReviewSlotMap(reviewPayload) { + return Object.fromEntries( + (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item]) + ) +} + +export function resolveExpenseTypeCode(value) { + const normalized = String(value || '').trim() + if (!normalized) return 'other' + if (EXPENSE_TYPE_LABELS[normalized]) return normalized + const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized) + return matched?.[0] || 'other' +} + +export function isValidIsoDateString(value) { + const normalized = String(value || '').trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return false + } + + const [yearText, monthText, dayText] = normalized.split('-') + const year = Number(yearText) + const month = Number(monthText) + const day = Number(dayText) + + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + return false + } + + const candidate = new Date(Date.UTC(year, month - 1, day)) + return ( + candidate.getUTCFullYear() === year && + candidate.getUTCMonth() === month - 1 && + candidate.getUTCDate() === day + ) +} + +export function parseAmountNumber(value) { + const normalized = String(value || '') + .replace(/[,,\s]/g, '') + .replace(/[¥¥]/g, '') + .replace(/元/g, '') + .trim() + + if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) { + return null + } + + const amount = Number(normalized) + return Number.isFinite(amount) ? amount : null +} + +export function normalizeAmountValue(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return '' + } + return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元` +} + +export function extractAmountInputValue(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return String(value || '').trim() + } + return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '') +} + +export function formatAmountDisplay(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return String(value || '').trim() + } + + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, + maximumFractionDigits: Number.isInteger(amount) ? 0 : 2 + }).format(amount) +} + +export function matchPresetSceneFromReason(reason) { + const compactReason = String(reason || '').trim().replace(/\s+/g, '') + if (!compactReason) { + return '' + } + if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) { + return '请客户吃饭' + } + if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) { + const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, ''))) + if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) { + return matchedPreset + } + } + if (/出差|差旅/.test(compactReason)) { + return '出差行程' + } + if (/酒店|住宿/.test(compactReason)) { + return '住宿报销' + } + if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) { + return '交通出行' + } + if (/会务|会议|参会|论坛|展会/.test(compactReason)) { + return '会务活动' + } + return '' +} + +export function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') { + const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)] + if (fromCode) { + return fromCode + } + + const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '') + if (!compactLabel) { + return '' + } + if (/差旅|出差/.test(compactLabel)) { + return '出差行程' + } + if (/住宿|酒店/.test(compactLabel)) { + return '住宿报销' + } + if (/交通/.test(compactLabel)) { + return '交通出行' + } + if (/招待|餐饮|餐费|伙食/.test(compactLabel)) { + return '请客户吃饭' + } + if (/会务|会议/.test(compactLabel)) { + return '会务活动' + } + return '' +} + +export function mapExpenseTypeLabelToPresetScene(expenseType) { + const code = resolveExpenseTypeCode(expenseType) + if (EXPENSE_CODE_TO_PRESET_SCENE[code]) { + return EXPENSE_CODE_TO_PRESET_SCENE[code] + } + + const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '') + if (!compactLabel) { + return '' + } + if (compactLabel.includes('差旅') || compactLabel.includes('出差')) { + return '出差行程' + } + if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) { + return '住宿报销' + } + if (compactLabel.includes('交通')) { + return '交通出行' + } + if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) { + return '请客户吃饭' + } + if (compactLabel.includes('会务') || compactLabel.includes('会议')) { + return '会务活动' + } + return matchPresetSceneFromReason(expenseType) +} + +export function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (documents.length) { + const votes = new Map() + for (const document of documents) { + const preset = + mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type) + || mapExpenseTypeLabelToPresetScene(document.suggested_expense_type) + if (!preset) { + continue + } + votes.set(preset, (votes.get(preset) || 0) + 1) + } + if (votes.size) { + return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0] + } + } + + const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : [] + if (claimGroups.length === 1) { + const group = claimGroups[0] + const preset = + mapExpenseTypeLabelToPresetScene(group.expense_type) + || mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type) + if (preset) { + return preset + } + } + + const fromReason = matchPresetSceneFromReason(reasonValue) + if (fromReason) { + return fromReason + } + + const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType) + if (fromExpenseType) { + return fromExpenseType + } + + if (String(reasonValue || '').trim()) { + return REVIEW_SCENE_OTHER_OPTION + } + return '待补充' +} + +export function formatReviewSceneDisplayValue(inlineState) { + const scene = String(inlineState?.scene_label || '').trim() + if (!scene || scene === '待补充') { + return '待补充' + } + if (scene === REVIEW_SCENE_OTHER_OPTION) { + const detail = String(inlineState?.reason_value || '').trim() + if (!detail) { + return REVIEW_SCENE_OTHER_OPTION + } + return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}:${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}:${detail}` + } + return scene +} + +export function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) { + return inferPresetSceneFromReview(reviewPayload, reason, expenseType) +} + +export function buildInlineReviewState(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields) + const attachmentNames = String( + editFieldMap.attachment_names?.value || + slotMap.attachments?.value || + (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '') + ).trim() + const attachmentCount = Array.isArray(reviewPayload?.document_cards) + ? reviewPayload.document_cards.length + : attachmentNames + ? attachmentNames.split('、').filter(Boolean).length + : 0 + const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim() + const reasonValue = String( + editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || '' + ).trim() + const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType) + const transportType = String( + editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue) + ).trim() + + return { + occurred_date: String( + editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || '' + ).trim(), + amount: normalizeAmountValue( + String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() + ), + transport_type: transportType, + scene_label: sceneLabel, + reason_value: + sceneLabel === REVIEW_SCENE_OTHER_OPTION + ? reasonValue + : String(slotMap.reason?.raw_value || '').trim() || reasonValue, + customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(), + location: String( + editFieldMap.business_location?.value || + editFieldMap.location?.value || + slotMap.location?.normalized_value || + slotMap.location?.value || + '' + ).trim(), + merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(), + participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(), + attachment_names: attachmentNames, + attachment_count: attachmentCount, + pending_attachment_count: 0, + expense_type: expenseType + } +} + +export function buildReviewAttachmentStatus(reviewPayload) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (!documents.length) return '未上传' + return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份` +} + +export function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') { + const slotMap = buildReviewSlotMap(reviewPayload) + const slot = slotMap[slotKey] + return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing' +} + +export function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const slotMap = buildReviewSlotMap(reviewPayload) + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + + return [ + String(inlineState.reason_value || '').trim(), + String(inlineState.scene_label || '').trim(), + String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(), + ...documents.map((item) => + [item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])] + .filter(Boolean) + .join(' ') + ) + ] + .filter(Boolean) + .join(' ') + .toLowerCase() +} + +export function resolveReviewCategoryTextScore(text, categoryCode) { + const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode] + if (!patterns?.length || !text) { + return 0 + } + return patterns.some((pattern) => pattern.test(text)) + ? { + travel: 0.84, + hotel: 0.82, + transport: 0.8, + meal: 0.76, + meeting: 0.78, + entertainment: 0.88, + office: 0.74, + training: 0.77, + communication: 0.7, + welfare: 0.72 + }[categoryCode] || 0 + : 0 +} + +export function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const matchedScores = documents + .filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode) + .map((item) => Number(item?.avg_score || 0)) + .filter((score) => Number.isFinite(score) && score > 0) + + if (!matchedScores.length) { + return 0 + } + + return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length +} + +export function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { + const normalizedLabel = String(selectedLabel || '').trim() + if (!normalizedLabel) { + return 0 + } + + const selectedCode = resolveExpenseTypeCode(normalizedLabel) + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseSlot = slotMap.expense_type + const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '') + let score = 0 + + if (recognizedCode === selectedCode) { + score = Math.max(score, Number(expenseSlot?.confidence || 0)) + } + + score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode)) + score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode)) + + if (!score && normalizedLabel) { + score = selectedCode === 'other' ? 0.52 : 0.58 + } + + return Math.max(0, Math.min(0.98, Number(score.toFixed(2)))) +} + +export function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { + const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) + return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({ + ...item, + active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel, + confidenceLabel: item.is_other + ? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState)) + : formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)), + caption: item.is_other + ? selectedLabel && !presetLabels.includes(selectedLabel) + ? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}` + : '点击选择更多类型' + : `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`, + groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多' + })) +} + +export function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) { + return formatConfidenceLabel( + resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState) + ) +} + +export function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) { + const state = inlineState || createEmptyInlineReviewState() + if (slotKey === 'expense_type') return String(state.expense_type || '').trim() + if (slotKey === 'customer_name') return String(state.customer_name || '').trim() + if (slotKey === 'time_range') return String(state.occurred_date || '').trim() + if (slotKey === 'location') return String(state.location || '').trim() + if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim() + if (slotKey === 'amount') return String(state.amount || '').trim() + if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim() + if (slotKey === 'participants') return String(state.participants || '').trim() + if (slotKey === 'attachments') { + return String(state.attachment_names || '').trim() + || (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '') + || (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '') + } + return '' +} + +export function buildLocallySyncedReviewActions(reviewPayload, canProceed) { + const actions = Array.isArray(reviewPayload?.confirmation_actions) + ? reviewPayload.confirmation_actions.map((item) => ({ ...item })) + : [] + const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim())) + const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents') + + if (!canProceed || associationPending) { + return actions + } + + const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step') + if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) { + syncedActions.push({ + label: '保存为草稿', + action_type: 'save_draft', + description: '先暂存当前已识别信息,稍后仍可继续补充或提交。', + emphasis: 'secondary' + }) + } + + return [ + ...syncedActions, + { + label: '继续下一步', + action_type: 'next_step', + description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。', + emphasis: 'primary' + } + ] +} + +export function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { + if (!reviewPayload || typeof reviewPayload !== 'object') { + return reviewPayload + } + + const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => { + const value = resolveInlineReviewSlotValue(slot.key, inlineState) + const required = Boolean(slot.required) + const filled = Boolean(value) + return { + ...slot, + value: value || slot.value || '', + normalized_value: value || slot.normalized_value || '', + raw_value: value || slot.raw_value || '', + source: filled ? 'user_form' : slot.source, + source_label: filled ? '用户修改' : slot.source_label, + confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0), + confirmed: filled || Boolean(slot.confirmed), + status: required && !filled ? 'missing' : filled ? 'identified' : slot.status, + hint: required && !filled ? slot.hint : '' + } + }) + const missingSlots = nextSlotCards + .filter((slot) => slot.required && slot.status === 'missing') + .map((slot) => slot.label || slot.key) + const extraMissingSlots = resolveReviewExtraMissingLabels({ + ...reviewPayload, + slot_cards: nextSlotCards + }) + const allMissingSlots = [...missingSlots, ...extraMissingSlots] + const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true) + + return { + ...reviewPayload, + can_proceed: canProceed, + missing_slots: allMissingSlots, + slot_cards: nextSlotCards, + confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed) + } +} + +export function normalizeInlineReviewComparableState(state) { + const source = state && typeof state === 'object' ? state : {} + return { + occurred_date: String(source.occurred_date || '').trim(), + amount: String(source.amount || '').trim(), + transport_type: String(source.transport_type || '').trim(), + scene_label: String(source.scene_label || '').trim(), + reason_value: String(source.reason_value || '').trim(), + customer_name: String(source.customer_name || '').trim(), + location: String(source.location || '').trim(), + merchant_name: String(source.merchant_name || '').trim(), + participants: String(source.participants || '').trim(), + attachment_names: String(source.attachment_names || '').trim(), + pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)), + expense_type: String(source.expense_type || '').trim() + } +} + +export function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) { + const base = normalizeInlineReviewComparableState(baseState) + const next = normalizeInlineReviewComparableState(nextState) + const lines = [] + + if (base.occurred_date !== next.occurred_date) { + lines.push(`发生时间 ${next.occurred_date || '待补充'}`) + } + if (base.amount !== next.amount) { + lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`) + } + if (base.transport_type !== next.transport_type) { + lines.push(`交通类型 ${next.transport_type || '待确认'}`) + } + if (base.scene_label !== next.scene_label) { + lines.push(`场景 ${next.scene_label || '待补充'}`) + } + if (base.customer_name !== next.customer_name) { + lines.push(`关联客户 ${next.customer_name || '待补充'}`) + } + if (base.location !== next.location) { + lines.push(`业务地点 ${next.location || '待补充'}`) + } + if (base.merchant_name !== next.merchant_name) { + lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`) + } + if (base.participants !== next.participants) { + lines.push(`同行人员 ${next.participants || '待补充'}`) + } + if (base.expense_type !== next.expense_type) { + lines.push(`报销分类 ${next.expense_type || '待补充'}`) + } + if (base.attachment_names !== next.attachment_names || pendingFiles.length) { + lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) + } + + return lines +} + +export function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) { + const base = normalizeInlineReviewComparableState(baseState) + const next = normalizeInlineReviewComparableState(nextState) + const fieldConfigs = [ + { key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' }, + { key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' }, + { key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' }, + { key: 'scene_label', label: '场景', format: (value) => value || '待补充' }, + { key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' }, + { key: 'location', label: '业务地点', format: (value) => value || '待补充' }, + { key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' }, + { key: 'participants', label: '同行人员', format: (value) => value || '待补充' }, + { key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' } + ] + + const phrases = fieldConfigs.reduce((result, item) => { + if (base[item.key] !== next[item.key]) { + result.push(`${item.label}修改为 ${item.format(next[item.key])}`) + } + return result + }, []) + + if (base.attachment_names !== next.attachment_names || pendingFiles.length) { + phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) + } + + return phrases +} + +export function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { + const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles) + const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + + if (documentLines.length) { + phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`) + } + + if (!phrases.length) { + return '右侧核对信息已保存。' + } + + return `已将${phrases.join(',')}。` +} + +export function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { + const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) + if (!lines.length) { + return '我已校正核对信息,请按最新内容更新。' + } + return `我已校正核对信息:${lines.join(',')}。请按最新内容更新。` +} + +export function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { + const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) + const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + + if (!inlineLines.length && !documentLines.length) { + return '我已校正核对信息,请按最新内容更新。' + } + + const parts = [] + if (inlineLines.length) { + parts.push(inlineLines.join(',')) + } + if (documentLines.length) { + parts.push(`修正了 ${documentLines.length} 张票据识别信息`) + } + + return `我已校正核对信息:${parts.join(';')}。请按最新内容更新。` +} + +export function mergeInlineReviewFields(baseFields, inlineState) { + const merged = cloneReviewEditFields(baseFields) + const updateMap = { + expense_type: inlineState.expense_type, + transport_type: inlineState.transport_type, + occurred_date: inlineState.occurred_date, + amount: inlineState.amount, + customer_name: inlineState.customer_name, + business_location: inlineState.location, + merchant_name: inlineState.merchant_name, + participants: inlineState.participants, + reason: inlineState.reason_value || inlineState.scene_label, + attachment_names: inlineState.attachment_names + } + + for (const item of merged) { + if (!(item.key in updateMap)) continue + item.value = String(updateMap[item.key] || '').trim() + } + + return merged +} + + +function resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver = null) { + if (typeof riskBriefResolver === 'function') { + return riskBriefResolver(reviewPayload) + } + return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : [] +} + +export function buildClientTimeContext() { + const now = new Date() + const locale = + typeof navigator !== 'undefined' && typeof navigator.language === 'string' + ? navigator.language + : 'zh-CN' + + return { + client_now_iso: now.toISOString(), + client_timezone_offset_minutes: now.getTimezoneOffset(), + client_locale: locale + } +} + + + +export function formatDraftApplyTime(date = new Date()) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + + + +export function formatDateInputValue(date = new Date()) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + + + +export function buildDraftSavedPayload({ + draftPayload, + reviewPayload, + inlineState, + linkedRequest, + currentUser, + riskItems = [] +}) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const missingItems = resolveReviewMissingSlotCards(reviewPayload) + const typeCode = resolveExpenseTypeCode(inlineState?.expense_type) + const amountNumber = parseAmountNumber(inlineState?.amount) + const location = String(inlineState?.location || linkedRequest?.city || '').trim() + const customerName = String(inlineState?.customer_name || '').trim() + const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim() + const title = + String(inlineState?.reason_value || '').trim() + || String(inlineState?.scene_label || '').trim() + || String(draftPayload?.title || '').trim() + || `${typeLabel}报销草稿` + const sceneLabel = + String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel + const attachmentSummary = documents.length + ? `${documents.length} 条识别票据 / ${documents.length} 份材料` + : String(inlineState?.attachment_names || '').trim() + ? '1 条识别票据 / 1 份材料' + : '待上传票据' + + return { + claimId: String(draftPayload?.claim_id || '').trim(), + claimNo: String(draftPayload?.claim_no || '').trim(), + status: String(draftPayload?.status || '').trim(), + approvalStage: String(draftPayload?.approval_stage || '').trim(), + person: String(currentUser?.name || '').trim() || '当前用户', + dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门', + entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.', + typeCode, + typeLabel, + detailVariant: typeCode === 'travel' ? 'travel' : 'general', + title, + sceneLabel, + sceneTarget: location || customerName || '待补充', + location, + relatedCustomer: customerName, + occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充', + applyTime: formatDraftApplyTime(), + amount: amountNumber === null ? 0 : amountNumber, + secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', + secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据', + secondaryStatusTone: documents.length ? 'warning' : 'neutral', + riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'), + attachmentSummary, + expenseTableSummary: documents.length + ? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认` + : '当前尚未上传票据,请在报销页继续补充附件', + note: String(draftPayload?.status || '').trim() === 'submitted' + ? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。' + : '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。' + } +} + + +export function countReviewPendingItems(reviewPayload) { + return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length +} + + + +export function countReviewRiskItems(reviewPayload, riskBriefResolver = null) { + return resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length +} + + + +export function buildReviewHeadline(reviewPayload) { + if (countReviewPendingItems(reviewPayload)) { + return '待补充信息' + } + if (reviewPayload?.can_proceed) { + return '识别结果已整理完成' + } + return '识别结果摘要' +} + + + +export function buildReviewSubline(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + + if (pendingCount) { + return `我已把 ${pendingCount} 项待补充内容整理成文字说明,请先核查。` + } + if (reviewPayload?.can_proceed) { + return '当前关键信息已基本齐全,确认无误后可以继续下一步。' + } + return '已为您整理本轮识别结果,请核查当前识别摘要。' +} + + + +export function buildReviewStateLabel(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + if (pendingCount) return `待补充 ${pendingCount} 项` + if (reviewPayload?.can_proceed) return '可继续处理' + return '已识别' +} + + + +export function buildReviewStateTone(reviewPayload) { + return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload) + ? 'ready' + : 'pending' +} + + + +export function buildReviewDisclosureTitle(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + if (pendingCount) { + return `当前有 ${pendingCount} 项待补充,点击展开查看` + } + return '当前信息已齐全,可展开查看识别摘要' +} + + + +export function buildReviewDisclosureHint(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + if (pendingCount) { + return '展开后可查看待补充字段和处理建议' + } + return '展开后可查看本轮已识别的关键信息' +} + + + +export function shouldOpenReviewDisclosure(reviewPayload) { + return !countReviewPendingItems(reviewPayload) +} + + + +export function buildReviewTodoSectionTitle(reviewPayload) { + return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息' +} + + + +export function buildReviewTodoSectionMeta(reviewPayload) { + const count = buildReviewTodoItems(reviewPayload).length + if (countReviewPendingItems(reviewPayload)) { + return count ? `${count} 项` : '待确认' + } + return count ? `${count} 项` : '已齐全' +} + + + +export function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { + if (slotKey === 'customer_name') { + return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户' + } + if (slotKey === 'participants') return '缺少同行人员' + if (slotKey === 'attachments') return '票据状态待补充' + if (slotKey === 'amount') return '金额待确认' + if (slotKey === 'time_range') return '发生时间待确认' + if (slotKey === 'reason') return '场景 / 事由待补充' + if (slotKey === 'expense_type') return '报销类型待确认' + if (slotKey === 'location') return '业务地点待补充' + if (slotKey === 'merchant_name') return '酒店/商户待补充' + return '仍有信息待补充' +} + + + +export function buildReviewAlertChips(reviewPayload, riskBriefResolver = null) { + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() + const chips = [] + + for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) { + chips.push({ + key: item.key, + label: buildReviewAlertLabel(item.key, expenseTypeLabel), + tone: 'warning' + }) + } + + if (chips.length < 3) { + for (const label of resolveReviewExtraMissingLabels(reviewPayload)) { + chips.push({ + key: label, + label, + tone: 'warning' + }) + if (chips.length >= 3) break + } + } + + if (chips.length < 3) { + for (const risk of resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver)) { + if (chips.some((item) => item.label === risk.title)) continue + chips.push({ + key: risk.title, + label: risk.title, + tone: risk.level === 'high' ? 'danger' : 'warning' + }) + if (chips.length >= 3) break + } + } + + if (!chips.length && reviewPayload?.can_proceed) { + chips.push({ + key: 'ready', + label: '当前识别信息已可继续处理', + tone: 'success' + }) + } + + return chips +} + + + +export function buildReviewTodoItems(reviewPayload) { + const missingItems = resolveReviewMissingSlotCards(reviewPayload) + const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload) + if (missingItems.length || extraMissingLabels.length) { + return [ + ...missingItems.map((item) => { + const config = REVIEW_SLOT_CONFIG[item.key] || {} + return { + key: item.key, + icon: config.icon || 'mdi mdi-form-select', + title: config.title || item.label, + hint: item.hint || config.hint || `请补充${item.label}`, + status: config.status || '待补充', + tone: 'warning' + } + }), + ...extraMissingLabels.map((label, index) => ({ + key: `extra-missing-${index}-${label}`, + icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline', + title: label, + hint: label.includes('必须') + ? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。' + : '可以继续补充该材料;如暂时没有,也可以按当前信息处理。', + status: label.includes('必须') ? '必须补齐' : '可选补充', + tone: 'warning' + })) + ] + } + + return resolveReviewRecognizedSlotCards(reviewPayload) + .filter((item) => String(item?.value || '').trim()) + .slice(0, 3) + .map((item) => { + const config = REVIEW_SLOT_CONFIG[item.key] || {} + return { + key: item.key, + icon: config.icon || 'mdi mdi-check-circle-outline', + title: config.title || item.label, + hint: `已识别:${item.value}`, + status: '已识别', + tone: 'ready' + } + }) +} + + + +const REVIEW_PENDING_HINT_COPY = { + expense_type: '请选择本次报销分类,后续票据会按这个分类继续核对。', + customer_name: '请补充客户单位全称。', + time_range: '请补充业务发生日期或时间范围。', + location: '请补充业务发生地点。', + merchant_name: '请补充酒店或商户名称。', + amount: '请补充本次费用金额。', + reason: '请补充本次费用场景或事由。', + participants: '请至少填写 1 名同行人员。', + attachments: '请上传或关联对应票据附件。' +} + +function normalizeReviewFollowupSentence(text) { + const normalized = String(text || '') + .replace(/^已识别[::]\s*/, '') + .replace(/^建议补充\s*/, '请补充') + .replace(/\s+/g, ' ') + .trim() + if (!normalized) return '' + return /[。!?.!?]$/.test(normalized) ? normalized : `${normalized}。` +} + +function buildReviewPlainFollowupItem(item, pendingMode) { + const key = String(item?.key || '').trim() + const label = String(item?.title || item?.label || '').trim() || '待核查信息' + if (pendingMode) { + return { + key: key || label, + label, + text: normalizeReviewFollowupSentence(REVIEW_PENDING_HINT_COPY[key] || item?.hint || `请补充${label}`) + } + } + + const value = normalizeReviewFollowupSentence(item?.hint || '') + return { + key: key || label, + label, + text: value || '已识别,请核查是否准确。' + } +} + +export function buildReviewPlainFollowupCopy(reviewPayload) { + const todoItems = buildReviewTodoItems(reviewPayload) + const pendingCount = countReviewPendingItems(reviewPayload) + const riskBriefs = resolvePresentationRiskBriefs(reviewPayload) + + if (pendingCount || resolveReviewExtraMissingLabels(reviewPayload).length) { + return { + lead: '我还需要你核查或补充下面这些信息:', + items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)), + notes: riskBriefs.length + ? [`另外还有 ${riskBriefs.length} 条风险提醒,提交前建议一起确认。`] + : [] + } + } + + return { + lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。', + items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)), + notes: [ + reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '', + riskBriefs.length ? `系统同时保留了 ${riskBriefs.length} 条风险提醒,请在提交前核查。` : '' + ].filter(Boolean) + } +} + + + +export function resolveReviewPrimaryAction(reviewPayload) { + return ( + (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( + (item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || '')) + ) || null + ) +} + + + +export function resolveReviewSaveDraftAction(reviewPayload) { + return ( + (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( + (item) => String(item?.action_type || '') === 'save_draft' + ) || null + ) +} + + + +export function resolveReviewFooterActions(reviewPayload) { + return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => { + const actionType = String(item?.action_type || '').trim() + return ['next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType) + }) +} + + + +export function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) { + const action = resolveReviewPrimaryAction(reviewPayload) + if (!action) return '确认' + if (action.action_type === 'save_draft') { + return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿' + } + if (action.action_type === 'next_step') { + return '继续下一步' + } + if (action.action_type === 'link_to_existing_draft') { + return action.label || '关联到现有草稿' + } + if (action.action_type === 'create_new_claim_from_documents') { + return action.label || '单独建立报销单' + } + return action.label || '确认' +} + + + +export function buildReviewIntentText(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseType = String(slotMap.expense_type?.value || '').trim() + if (expenseType) { + return `报销一笔${expenseType}` + } + return '发起一笔报销' +} + + + +export function buildReviewSceneValue(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim() + const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim() + return inferPresetSceneFromReview(reviewPayload, reason, expenseType) +} + + + +export function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { + if (slotKey === 'customer_name') { + return expenseTypeLabel === '业务招待费' + ? '业务招待费需补充客户单位名称,以便进行合规校验。' + : '当前仍缺少客户单位名称,建议补充后再提交。' + } + if (slotKey === 'participants') { + return '缺少同行人员信息,建议补充至少 1 名。' + } + if (slotKey === 'attachments') { + return '尚未上传票据附件,当前无法完成票据核对。' + } + if (slotKey === 'amount') { + return '报销金额仍待确认,提交前需补齐金额信息。' + } + if (slotKey === 'time_range') { + return '业务发生时间仍待确认,建议补充准确日期。' + } + if (slotKey === 'reason') { + return '报销事由说明仍不完整,建议补充业务背景。' + } + return '当前仍有识别信息待补充,建议先核对后再处理。' +} + + + +export function buildReviewRiskSummary(reviewPayload, riskBriefResolver = null) { + if (resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length) { + return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。' + } + return '当前没有需要额外处理的结构化风险点。' +} + + + +export function normalizeReviewRiskLevel(level) { + const normalized = String(level || '').trim().toLowerCase() + if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high' + if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium' + if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low' + if (normalized === 'high') return normalized + return 'low' +} + + + +export function buildLocalReviewCompletionMessage(reviewPayload) { + const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : [] + if (reviewPayload?.can_proceed && !missingSlots.length) { + return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。' + } + if (missingSlots.length) { + return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}。` + } + return '当前信息已保存,可以继续核对右侧状态。' +} + + + +export function buildReviewRecognitionNotes(reviewPayload) { + const recognized = resolveReviewRecognizedSlotCards(reviewPayload) + const notes = [] + const timeSlot = recognized.find((item) => item.key === 'time_range') + const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] + + if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { + notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) + } + + if (sourceLabels.length) { + notes.push(`本轮主要依据:${sourceLabels.join('、')}`) + } + + const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (documentCards.length) { + notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) + } else { + notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别') + } + + return notes +} + + + +export function buildReviewMissingHint(reviewPayload) { + if (!countReviewPendingItems(reviewPayload)) { + return '' + } + if (reviewPayload?.can_proceed) { + return '当前关键信息已经齐全,这里无需再补充。' + } + return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。' +} + + + +export function buildReviewRiskHint(reviewPayload, riskBriefResolver = null) { + const riskBriefs = resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver) + if (!riskBriefs.length) { + return '' + } + return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。' +} + + + +export function buildReviewActionHint(reviewPayload) { + if (reviewPayload?.can_proceed) { + return '如果识别无误,可以继续下一步;如果有偏差,请直接在右侧核对信息中修改。' + } + return '如果现在信息还不完整,可以先保存草稿;识别错了请直接在右侧核对信息中修改。' +} + + + +export function buildReviewStatusTag(reviewPayload) { + const missingCount = countReviewPendingItems(reviewPayload) + if (reviewPayload?.can_proceed) { + return '可继续处理' + } + if (missingCount > 0) { + return `待补充 ${missingCount} 项` + } + return '待确认' +} diff --git a/web/src/views/scripts/travelRequestDetailExpenseModel.js b/web/src/views/scripts/travelRequestDetailExpenseModel.js new file mode 100644 index 0000000..46b9bf2 --- /dev/null +++ b/web/src/views/scripts/travelRequestDetailExpenseModel.js @@ -0,0 +1,545 @@ +export const EXPENSE_TYPE_OPTIONS = [ + { value: 'travel', label: '差旅费' }, + { value: 'train_ticket', label: '火车票' }, + { value: 'flight_ticket', label: '机票' }, + { value: 'hotel_ticket', label: '住宿票' }, + { value: 'ride_ticket', label: '乘车' }, + { value: 'entertainment', label: '业务招待费' }, + { value: 'office', label: '办公费' }, + { value: 'meeting', label: '会务费' }, + { value: 'training', label: '培训费' }, + { value: 'hotel', label: '住宿费' }, + { value: 'transport', label: '交通费' }, + { value: 'meal', label: '餐费' }, + { value: 'travel_allowance', label: '出差补贴' }, + { value: 'other', label: '其他费用' } +] + +export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ + 'travel', + 'meeting', + 'entertainment' +]) + +export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) +export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) +export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) +export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) +export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/ + +export function parseCurrency(value) { + return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 +} + +export function formatCurrency(value) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 0, + maximumFractionDigits: Number.isInteger(value) ? 0 : 2 + }).format(value) +} + +export function normalizeExpenseType(value) { + return String(value || '').trim() || 'other' +} + +export function resolveExpenseTypeLabel(value) { + return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用' +} + +export function isSystemGeneratedExpenseItemSource(source) { + const itemType = normalizeExpenseType(source?.itemType || source?.item_type) + return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) +} + +export function isLocationRequiredExpenseType(value) { + return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value)) +} + +export function resolveLocationSummaryLabel(value) { + return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点' +} + +export function isRouteDescriptionExpenseType(value) { + return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value)) +} + +export function isHotelDescriptionExpenseType(value) { + return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value)) +} + +export function resolveExpenseDetailHint(expenseType) { + if (isRouteDescriptionExpenseType(expenseType)) { + return '起始地-目的地' + } + if (isHotelDescriptionExpenseType(expenseType)) { + return '目的地酒店' + } + if (!isLocationRequiredExpenseType(expenseType)) { + return '非必填' + } + return '待补充' +} + +export function resolveLocationDisplay(value, expenseType) { + return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value +} + +export function isSyntheticLocationDisplay(value, expenseType) { + const text = String(value || '').trim() + return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text) +} + +export function isValidRouteDescription(value) { + const text = String(value || '').trim() + return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text) +} + +export function resolveExpenseReasonPlaceholder(itemType) { + if (isRouteDescriptionExpenseType(itemType)) { + return '起始地-目的地,例如:广州南-北京南' + } + if (isHotelDescriptionExpenseType(itemType)) { + return '目的地酒店,例如:北京中心酒店' + } + return '输入费用说明' +} + +export function resolveExpenseReasonHelper(itemType) { + if (isRouteDescriptionExpenseType(itemType)) { + return '起始地-目的地' + } + if (isHotelDescriptionExpenseType(itemType)) { + return '目的地酒店' + } + return '业务报销说明' +} + +export function buildFallbackProgressSteps() { + return [ + { index: 1, label: '创建单据', time: '已完成', done: true, active: true }, + { index: 2, label: '待提交', time: '进行中', active: true, current: true }, + { index: 3, label: 'AI预审', time: '待处理' }, + { index: 4, label: '直属领导审批', time: '待处理' }, + { index: 5, label: '财务审批', time: '待处理' }, + { index: 6, label: '归档入账', time: '待处理' } + ] +} + +export function buildFallbackExpenseItems(request) { + return [ + buildExpenseItemViewModel({ + id: 'fallback-1', + itemDate: '', + itemType: request.typeCode || 'other', + itemReason: request.reason, + itemLocation: request.sceneTarget, + itemAmount: parseCurrency(request.amountDisplay), + invoiceId: '', + time: '待补充', + dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日', + name: request.typeLabel, + category: request.typeLabel, + desc: request.reason, + detail: resolveLocationDisplay(request.sceneTarget, request.typeCode), + amount: request.amountDisplay, + status: '待补充', + tone: 'bad', + attachmentStatus: '待上传', + attachmentHint: '请在此单据中继续补充附件', + attachmentTone: 'missing', + attachments: [], + riskLabel: '待补材料', + riskText: request.riskSummary, + riskTone: 'medium' + }, 0, request) + ] +} + +export function isPlaceholderValue(value) { + const text = String(value || '').trim() + if (!text) { + return true + } + + return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) +} + +export function normalizeDetailNoteDraftValue(value) { + const text = String(value || '').trim() + return isPlaceholderValue(text) ? '' : text +} + +export function isValidIsoDate(value) { + const normalized = String(value || '').trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return false + } + + const [yearText, monthText, dayText] = normalized.split('-') + const year = Number(yearText) + const month = Number(monthText) + const day = Number(dayText) + + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + return false + } + + const candidate = new Date(Date.UTC(year, month - 1, day)) + return ( + candidate.getUTCFullYear() === year && + candidate.getUTCMonth() === month - 1 && + candidate.getUTCDate() === day + ) +} + +export function normalizeIsoDateValue(value) { + const normalized = String(value || '').trim() + if (isValidIsoDate(normalized)) { + return normalized + } + + const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/) + if (match && isValidIsoDate(match[1])) { + return match[1] + } + + const candidate = value instanceof Date ? value : new Date(normalized) + if (Number.isNaN(candidate.getTime())) { + return '' + } + + const year = candidate.getFullYear() + const month = String(candidate.getMonth() + 1).padStart(2, '0') + const day = String(candidate.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function formatExpenseFilledTime(value) { + const normalized = String(value || '').trim() + if (!normalized) { + return '' + } + + const candidate = value instanceof Date ? value : new Date(normalized) + if (Number.isNaN(candidate.getTime())) { + return normalized + } + + const year = candidate.getFullYear() + const month = String(candidate.getMonth() + 1).padStart(2, '0') + const day = String(candidate.getDate()).padStart(2, '0') + const hours = String(candidate.getHours()).padStart(2, '0') + const minutes = String(candidate.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + +export function resolveExpenseUploadHint(value) { + const normalized = String(value || '').trim() + return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据' +} + +export function extractAttachmentDisplayName(value) { + const normalized = String(value || '').trim() + if (!normalized) { + return '' + } + + return normalized.split('/').filter(Boolean).pop() || normalized +} + +export function resolveExpenseItemViewId(source, index, requestModel) { + return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`) +} + +export function buildTravelTimeLabelMap(items, requestModel) { + const travelItems = items + .map((item, index) => { + const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other') + return { + id: resolveExpenseItemViewId(item, index, requestModel), + index, + itemType, + itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date), + isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType }) + } + }) + .filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType)) + .sort((left, right) => { + const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || '')) + return dateCompare || left.index - right.index + }) + + const labels = new Map() + if (!travelItems.length) { + return labels + } + + travelItems.forEach((item, index) => { + if (index === 0) { + labels.set(item.id, '出发时间') + } else if (index === travelItems.length - 1) { + labels.set(item.id, '返回时间') + } else { + labels.set(item.id, '中转时间') + } + }) + return labels +} + +export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) { + if (isSystemGenerated) { + return '系统自动计算' + } + if (travelTimeLabelMap?.has(id)) { + return travelTimeLabelMap.get(id) + } + if (itemType === 'ride_ticket') { + return '乘车时间' + } + if (itemType === 'hotel_ticket') { + return '住宿时间' + } + return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间' +} + +export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) { + const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other') + const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType }) + const id = resolveExpenseItemViewId(source, index, requestModel) + const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim() + const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim() + const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date) + const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount) + const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim() + const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim() + const attachments = invoiceId ? [attachmentName || invoiceId] : [] + const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充' + const riskText = String(source?.riskText || '').trim() + const filledAt = formatExpenseFilledTime( + source?.filledAt + || source?.filled_at + || source?.createdAt + || source?.created_at + ) + + return { + id, + itemDate, + itemType, + itemReason, + itemLocation, + itemAmount, + invoiceId, + isSystemGenerated, + time: itemDate || '待补充', + filledAt: filledAt || '待同步', + dayLabel: resolveExpenseTimeLabel({ + id, + itemType, + isSystemGenerated, + requestModel, + travelTimeLabelMap + }), + name: resolveExpenseTypeLabel(itemType), + category: resolveExpenseTypeLabel(itemType), + desc: itemReason || '待补充', + detail: resolveLocationDisplay(itemLocation, itemType), + amount: amountDisplay, + status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充', + tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad', + attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传', + attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(), + attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing', + attachments, + riskLabel: String(source?.riskLabel || '').trim() || '无', + riskText, + riskTone: String(source?.riskTone || '').trim() || 'low' + } +} + +export function rebuildExpenseItems(items, requestModel) { + const sortedItems = [...items] + .sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right))) + const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel) + return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap)) +} + +export function buildExpenseDraftIssues(item) { + const issues = [] + if (item.isSystemGenerated) { + return issues + } + const locationRequired = isLocationRequiredExpenseType(item.itemType) + + if (!isValidIsoDate(item.itemDate)) { + issues.push('缺少日期') + } + if (isPlaceholderValue(item.itemType)) { + issues.push('缺少费用项目') + } + if (isPlaceholderValue(item.itemReason)) { + issues.push('缺少说明') + } else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) { + issues.push('行程说明格式错误') + } + if (locationRequired && isPlaceholderValue(item.itemLocation)) { + issues.push('缺少地点') + } + if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) { + issues.push('缺少金额') + } + if (isPlaceholderValue(item.invoiceId)) { + issues.push('缺少票据标识') + } + + return issues +} + +export function buildOptionalTravelReceiptRiskCards(requestModel, items) { + const normalizedItems = Array.isArray(items) ? items : [] + const isTravelContext = + requestModel?.detailVariant === 'travel' || + requestModel?.typeCode === 'travel' || + normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType)) + if (!isTravelContext) { + return [] + } + + const hasUploadedType = (itemType) => + normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId)) + const cards = [] + if (!hasUploadedType('hotel_ticket')) { + cards.push({ + id: 'travel-optional-hotel-ticket', + tone: 'low', + label: '低风险', + title: '住宿票据提醒', + risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。', + summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。', + ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'], + suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。' + }) + } + if (!hasUploadedType('ride_ticket')) { + cards.push({ + id: 'travel-optional-ride-ticket', + tone: 'low', + label: '低风险', + title: '乘车票据提醒', + risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。', + summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。', + ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'], + suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。' + }) + } + return cards +} + +export function buildDraftBlockingIssues(request, expenseItems) { + const issues = [] + const locationRequired = isLocationRequiredExpenseType(request.typeCode) + const normalizedItems = Array.isArray(expenseItems) ? expenseItems : [] + const itemAmountTotal = normalizedItems.reduce((sum, item) => { + const amount = Number(item?.itemAmount || 0) + return Number.isFinite(amount) && amount > 0 ? sum + amount : sum + }, 0) + const hasValidItemDate = normalizedItems.some((item) => isValidIsoDate(item?.itemDate)) + const hasValidItemType = normalizedItems.some((item) => !isPlaceholderValue(item?.itemType)) + const hasValidItemReason = normalizedItems.some((item) => !isPlaceholderValue(item?.itemReason)) + const hasValidItemLocation = normalizedItems.some((item) => !isPlaceholderValue(item?.itemLocation)) + + if (isPlaceholderValue(request.profileName)) { + issues.push('申请人未完善') + } + if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) { + issues.push('报销类型未完善') + } + if (isPlaceholderValue(request.reason) && !hasValidItemReason) { + issues.push('报销事由未完善') + } + if (locationRequired && isPlaceholderValue(request.location) && !hasValidItemLocation) { + issues.push('业务地点未完善') + } + if (isPlaceholderValue(request.occurredDisplay) && !hasValidItemDate) { + issues.push('发生时间未完善') + } + if ((!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) && itemAmountTotal <= 0) { + issues.push('报销金额未完善') + } + if (!normalizedItems.length) { + issues.push('费用明细不能为空') + } + + normalizedItems.forEach((item, index) => { + buildExpenseDraftIssues(item).forEach((issue) => { + issues.push(`费用明细第 ${index + 1} 条${issue}`) + }) + }) + + return [...new Set(issues)] +} + +export function mapIssueToAdvice(issue) { + const text = String(issue || '').trim() + if (!text) { + return '' + } + + if (text === '费用明细不能为空') { + return '先新增至少 1 条费用明细,再补充金额、用途和附件。' + } + if (text === '申请人未完善') { + return '补充申请人信息,确保审批单据归属明确。' + } + if (text === '所属部门未完善') { + return '补充所属部门,便于财务和审批人识别成本归属。' + } + if (text === '报销类型未完善') { + return '选择报销类型,明确本次费用归类。' + } + if (text === '报销事由未完善') { + return '补充报销事由,说明本次费用用途。' + } + if (text === '业务地点未完善') { + return '补充业务地点,方便审核业务发生场景。' + } + if (text === '发生时间未完善') { + return '补充费用发生时间,确保单据时间完整。' + } + if (text === '报销金额未完善') { + return '补充报销金额,并与费用明细金额保持一致。' + } + + const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/) + if (!itemMatch) { + return text + } + + const [, indexText, fieldText] = itemMatch + const labelPrefix = `完善第 ${indexText} 条费用明细` + if (fieldText === '缺少日期') { + return `${labelPrefix}的发生日期。` + } + if (fieldText === '缺少费用项目') { + return `${labelPrefix}的费用项目。` + } + if (fieldText === '缺少说明') { + return `${labelPrefix}的用途说明。` + } + if (fieldText === '行程说明格式错误') { + return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。` + } + if (fieldText === '缺少地点') { + return `${labelPrefix}的业务地点。` + } + if (fieldText === '缺少金额') { + return `${labelPrefix}的金额。` + } + if (fieldText === '缺少票据标识') { + return `为第 ${indexText} 条费用明细上传或关联票据附件。` + } + + return `${labelPrefix}。` +} diff --git a/web/src/views/scripts/travelRequestDetailInsights.js b/web/src/views/scripts/travelRequestDetailInsights.js index 18cebeb..690ad6c 100644 --- a/web/src/views/scripts/travelRequestDetailInsights.js +++ b/web/src/views/scripts/travelRequestDetailInsights.js @@ -30,6 +30,62 @@ function normalizeTone(value) { return 'medium' } +export function normalizeRiskTone(value) { + return normalizeTone(value) +} + +export function resolveRiskTagTone(tag) { + const normalized = normalizeText(tag).toLowerCase() + if (normalized === '#high_risk') return 'high' + if (normalized === '#middle_risk') return 'medium' + if (normalized === '#low_risk') return 'low' + if (normalized === '#hotel') return 'hotel' + if (normalized === '#traffic') return 'traffic' + return 'neutral' +} + +export function extractRiskTagsFromText(text) { + const matches = normalizeText(text).match(/#[A-Za-z_]+/g) || [] + return [...new Set(matches.map((tag) => tag.toLowerCase()))] +} + +export function resolveRiskTags(card = {}) { + const tags = [] + const tone = normalizeTone(card.tone || card.severity) + if (tone === 'high') { + tags.push('#high_risk') + } else if (tone === 'medium') { + tags.push('#middle_risk') + } else if (tone === 'low') { + tags.push('#low_risk') + } + + const text = [ + card.label, + card.title, + card.risk, + card.summary, + card.suggestion, + card.itemType, + card.documentType + ].map((item) => normalizeText(item).toLowerCase()).join(' ') + if (/住宿|酒店|宾馆|hotel/.test(text)) { + tags.push('#hotel') + } + if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi|traffic|transport/.test(text)) { + tags.push('#traffic') + } + + return [...new Set(tags)] +} + +function withRiskTags(card) { + return { + ...card, + tags: resolveRiskTags(card) + } +} + function resolveDocumentTypeLabel(value) { return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other } @@ -109,7 +165,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy const tone = normalizeTone(analysis?.severity) const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险') - return { + return withRiskTags({ id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`, tone, label, @@ -117,8 +173,10 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。', summary: normalizeText(analysis?.summary), ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'], - suggestion: buildCardSuggestion(analysis, insight) - } + suggestion: buildCardSuggestion(analysis, insight), + itemType: normalizeText(item?.itemType), + documentType: normalizeText(insight?.documentTypeLabel) + }) } function parseReturnCount(flag) { @@ -170,7 +228,7 @@ function buildManualReturnRiskCard(flag) { ...riskPoints.map((item) => `退回风险点:${item}。`) ]) - return { + return withRiskTags({ id: `manual-return-${returnCount || 'latest'}`, tone: 'medium', label: '退回原因', @@ -179,7 +237,7 @@ function buildManualReturnRiskCard(flag) { summary: normalizeText(flag.reason), ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'], suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。' - } + }) } export function buildAttachmentRiskCards({ @@ -220,7 +278,7 @@ export function buildAttachmentRiskCards({ if (!flag || typeof flag !== 'object') { const risk = normalizeText(flag) return risk - ? [{ + ? [withRiskTags({ id: `claim-risk-${index}`, tone: 'medium', label: '单据风险', @@ -229,7 +287,7 @@ export function buildAttachmentRiskCards({ summary: '', ruleBasis: ['系统预审规则命中该风险提示。'], suggestion: '请结合业务背景补充说明或调整单据后再提交。' - }] + })] : [] } @@ -251,7 +309,7 @@ export function buildAttachmentRiskCards({ '系统预审规则命中该风险提示。' ]) - return risks.map((risk, pointIndex) => ({ + return risks.map((risk, pointIndex) => withRiskTags({ id: `claim-risk-${index}-${pointIndex}`, tone, label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'), diff --git a/web/src/views/scripts/useTravelReimbursementAttachments.js b/web/src/views/scripts/useTravelReimbursementAttachments.js new file mode 100644 index 0000000..98297dd --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementAttachments.js @@ -0,0 +1,305 @@ +import { computed, ref } from 'vue' + +function normalizeAttachmentMatchName(value) { + const fileName = String(value || '') + .trim() + .split(/[\\/]/) + .filter(Boolean) + .pop() || '' + return fileName + .toLowerCase() + .replace(/[^\w.\-\u4e00-\u9fff]+/g, '_') + .replace(/^[_\.]+|[_\.]+$/g, '') +} + +export function useTravelReimbursementAttachments({ + isKnowledgeSession, + reviewFilePreviews, + linkedRequest, + draftClaimId, + activeReviewPayload, + reviewInlinePendingFiles, + reviewInlineForm, + reviewInlineEditorKey, + composerUploadIntent, + submitting, + reviewActionBusy, + toast, + fileInputRef, + fetchExpenseClaimDetail, + fetchExpenseClaimItemAttachmentMeta, + fetchExpenseClaimAttachmentAsset, + uploadExpenseClaimItemAttachment, + extractReviewAttachmentNames, + mergeFilesWithLimit, + mergeFilePreviews, + resolveAttachmentPreviewKind, + resolveDocumentPreview, + buildFilePreviews, + buildFileIdentity, + MAX_ATTACHMENTS, + VISIBLE_ATTACHMENT_CHIPS, + clearInlineReviewFieldError +}) { + const fileInputMode = ref('composer') + const attachedFiles = ref([]) + const composerFilesExpanded = ref(false) + const previewRegistry = [] + const restoredDraftPreviewClaims = new Set() + + const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS)) + const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS)) + + function rememberFilePreviews(filePreviews) { + reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) + } + + function trackPreviewObjectUrl(url) { + if (!url || !String(url).startsWith('blob:')) { + return + } + previewRegistry.push(url) + } + + function buildComposerFilePreviews(files) { + const filePreviews = buildFilePreviews(files, previewRegistry) + rememberFilePreviews(filePreviews) + return filePreviews + } + + function resolveActiveClaimId() { + return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim() + } + + async function buildPersistedAttachmentPreview(metadata) { + const filename = String(metadata?.file_name || '').trim() + const kind = resolveAttachmentPreviewKind(metadata) + const previewPath = String(metadata?.preview_url || '').trim() + if (!filename || !kind || !previewPath) { + return null + } + + const blob = await fetchExpenseClaimAttachmentAsset(previewPath) + const url = URL.createObjectURL(blob) + trackPreviewObjectUrl(url) + return { + filename, + kind, + url + } + } + + async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) { + const normalizedClaimId = String(claimId || '').trim() + if (!normalizedClaimId || isKnowledgeSession.value) { + return + } + + const force = Boolean(options.force) + if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) { + return + } + + try { + const claim = await fetchExpenseClaimDetail(normalizedClaimId) + const items = Array.isArray(claim?.items) ? claim.items : [] + const previews = [] + + for (const item of items) { + const itemId = String(item?.id || '').trim() + if (!itemId) continue + + let metadata = null + try { + metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId) + } catch { + continue + } + + const filename = String(metadata?.file_name || '').trim() + if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) { + continue + } + + try { + const preview = await buildPersistedAttachmentPreview(metadata) + if (preview) { + previews.push(preview) + } + } catch (error) { + console.warn('Failed to load persisted attachment preview:', error) + } + } + + if (previews.length) { + rememberFilePreviews(previews) + } + restoredDraftPreviewClaims.add(normalizedClaimId) + } catch (error) { + console.warn('Failed to restore persisted draft attachment previews:', error) + } + } + + async function syncComposerFilesToDraft(claimId, files) { + const normalizedClaimId = String(claimId || '').trim() + if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) { + return + } + + const claim = await fetchExpenseClaimDetail(normalizedClaimId) + const items = Array.isArray(claim?.items) ? claim.items : [] + const exactMatchBuckets = new Map() + const normalizedMatchBuckets = new Map() + const placeholderQueue = [] + const usedItemIds = new Set() + + for (const item of items) { + const itemId = String(item?.id || '').trim() + const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim() + if (!itemId) continue + if (invoiceId && !invoiceId.includes('/')) { + placeholderQueue.push(item) + } + if (!invoiceId) continue + const bucket = exactMatchBuckets.get(invoiceId) || [] + bucket.push(item) + exactMatchBuckets.set(invoiceId, bucket) + + const normalizedInvoiceName = normalizeAttachmentMatchName(invoiceId) + if (normalizedInvoiceName) { + const normalizedBucket = normalizedMatchBuckets.get(normalizedInvoiceName) || [] + normalizedBucket.push(item) + normalizedMatchBuckets.set(normalizedInvoiceName, normalizedBucket) + } + } + + for (const file of files) { + const exactBucket = exactMatchBuckets.get(file.name) || [] + const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim())) + const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || [] + const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim())) + const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim())) + const targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch + const targetItemId = String(targetItem?.id || '').trim() + if (!targetItemId) { + continue + } + + usedItemIds.add(targetItemId) + await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file) + } + + await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true }) + } + + function triggerFileUpload(mode = 'composer') { + if (submitting.value || reviewActionBusy.value) return + fileInputMode.value = mode + fileInputRef.value?.click() + } + + function handleFilesChange(event) { + const files = Array.from(event.target.files ?? []) + + if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) { + const existingNames = extractReviewAttachmentNames(activeReviewPayload.value) + const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0) + const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots) + + if (!remainingSlots && files.length) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`) + } else if (mergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`) + } + + reviewInlinePendingFiles.value = mergeResult.files + const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)] + reviewInlineForm.value = { + ...reviewInlineForm.value, + attachment_names: allAttachmentNames.join('、'), + attachment_count: allAttachmentNames.length, + pending_attachment_count: mergeResult.files.length + } + clearInlineReviewFieldError('attachments') + reviewInlineEditorKey.value = '' + } else { + if (isKnowledgeSession.value) { + toast('财务知识问答暂不支持上传附件。') + fileInputMode.value = 'composer' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + return + } + + const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS) + attachedFiles.value = mergeResult.files + if (fileInputMode.value === 'composer-continue' && files.length) { + composerUploadIntent.value = 'continue_existing' + } + if (mergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } + if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { + composerFilesExpanded.value = false + } + } + + fileInputMode.value = 'composer' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + } + + function toggleAttachedFilesExpanded() { + composerFilesExpanded.value = !composerFilesExpanded.value + } + + function removeAttachedFile(targetFile) { + const fileKey = buildFileIdentity(targetFile) + attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey) + if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { + composerFilesExpanded.value = false + } + if (!attachedFiles.value.length) { + composerUploadIntent.value = '' + } + } + + function clearAttachedFiles() { + attachedFiles.value = [] + composerFilesExpanded.value = false + composerUploadIntent.value = '' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + } + + function stopAttachmentRuntime() { + for (const url of previewRegistry) { + URL.revokeObjectURL(url) + } + previewRegistry.length = 0 + } + + return { + fileInputMode, + attachedFiles, + composerFilesExpanded, + visibleAttachedFiles, + hiddenAttachedFileCount, + rememberFilePreviews, + trackPreviewObjectUrl, + buildComposerFilePreviews, + resolveActiveClaimId, + buildPersistedAttachmentPreview, + restorePersistedDraftAttachmentPreviews, + syncComposerFilesToDraft, + triggerFileUpload, + handleFilesChange, + toggleAttachedFilesExpanded, + removeAttachedFile, + clearAttachedFiles, + stopAttachmentRuntime + } +} diff --git a/web/src/views/scripts/useTravelReimbursementComposerTools.js b/web/src/views/scripts/useTravelReimbursementComposerTools.js new file mode 100644 index 0000000..013c624 --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementComposerTools.js @@ -0,0 +1,396 @@ +import { computed, nextTick, ref } from 'vue' + +export function useTravelReimbursementComposerTools({ + currentUser, + activeReviewPayload, + reviewInlineForm, + latestReviewMessage, + currentInsight, + messages, + composerDraft, + composerTextareaRef, + adjustComposerTextareaHeight, + scrollToBottom, + toast, + calculateTravelReimbursement, + createMessage, + buildReviewSlotMap, + isValidIsoDateString, + buildLocallySyncedReviewPayload, + formatDateInputValue +}) { + const composerDatePickerOpen = ref(false) + const composerDateMode = ref('single') + const composerSingleDate = ref(formatDateInputValue()) + const composerRangeStartDate = ref(formatDateInputValue()) + const composerRangeEndDate = ref(formatDateInputValue()) + const composerBusinessTimeTags = ref([]) + const composerBusinessTimeDraftTouched = ref(false) + const travelCalculatorOpen = ref(false) + const travelCalculatorBusy = ref(false) + const travelCalculatorError = ref('') + const travelCalculatorResult = ref(null) + const travelCalculatorForm = ref({ + days: '1', + location: '' + }) + const composerCanApplyDateSelection = computed(() => { + if (composerDateMode.value === 'single') { + return Boolean(composerSingleDate.value) + } + return Boolean( + composerRangeStartDate.value + && composerRangeEndDate.value + && composerRangeStartDate.value <= composerRangeEndDate.value + ) + }) + const travelCalculatorCanSubmit = computed(() => + !travelCalculatorBusy.value + && Number(travelCalculatorForm.value.days) >= 1 + && Boolean(String(travelCalculatorForm.value.location || '').trim()) + ) + function buildComposerBusinessTimeLabel() { + if (composerDateMode.value === 'single') { + return `业务发生时间:${composerSingleDate.value}` + } + if (composerRangeStartDate.value === composerRangeEndDate.value) { + return `业务发生时间:${composerRangeStartDate.value}` + } + return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}` + } + + function hasComposerBusinessTimeSelection() { + return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value + } + + function buildComposerBusinessTimeContext() { + if (!hasComposerBusinessTimeSelection()) { + return null + } + + const mode = composerDateMode.value === 'range' ? 'range' : 'single' + const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim() + const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim() + if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { + return null + } + + const displayValue = mode === 'range' && startDate !== endDate + ? `${startDate} 至 ${endDate}` + : startDate + return { + mode, + start_date: startDate, + end_date: endDate, + occurred_date: startDate, + time_range: displayValue, + business_time: displayValue, + time_range_raw: buildComposerBusinessTimeLabel() + } + } + + function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) { + if (!businessTimeContext) { + return extraContext + } + + const baseReviewFormValues = + extraContext.review_form_values && typeof extraContext.review_form_values === 'object' + ? extraContext.review_form_values + : {} + + return { + ...extraContext, + occurred_date: businessTimeContext.occurred_date, + business_time: businessTimeContext.business_time, + business_time_context: { + mode: businessTimeContext.mode, + start_date: businessTimeContext.start_date, + end_date: businessTimeContext.end_date, + display_value: businessTimeContext.business_time + }, + review_form_values: { + ...baseReviewFormValues, + occurred_date: businessTimeContext.occurred_date, + time_range: businessTimeContext.time_range, + business_time: businessTimeContext.business_time, + time_range_raw: businessTimeContext.time_range_raw + } + } + } + + function syncComposerBusinessTimeToReviewCard(businessTimeContext) { + if (!businessTimeContext || !activeReviewPayload.value) { + return + } + + const nextInlineState = { + ...reviewInlineForm.value, + occurred_date: businessTimeContext.occurred_date + } + const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState) + reviewInlineForm.value = nextInlineState + if (latestReviewMessage.value) { + latestReviewMessage.value.reviewPayload = nextReviewPayload + } + if (currentInsight.value?.agent) { + currentInsight.value = { + ...currentInsight.value, + agent: { + ...currentInsight.value.agent, + reviewPayload: nextReviewPayload + } + } + } + } + + function resolveComposerSubmitText(explicitRawText) { + const draftPart = String(explicitRawText ?? composerDraft.value).trim() + const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',') + if (!tagPart) { + return draftPart + } + if (!draftPart) { + return tagPart + } + return `${tagPart},${draftPart}` + } + + function toggleComposerDatePicker() { + composerDatePickerOpen.value = !composerDatePickerOpen.value + if (composerDatePickerOpen.value) { + travelCalculatorOpen.value = false + } + } + + function closeComposerDatePicker() { + composerDatePickerOpen.value = false + } + + function setComposerDateMode(mode) { + composerDateMode.value = mode === 'range' ? 'range' : 'single' + } + + function handleComposerDateInputChange() { + composerBusinessTimeDraftTouched.value = true + syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext()) + } + + function removeComposerBusinessTimeTag(tagId) { + composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId) + if (!composerBusinessTimeTags.value.length) { + composerBusinessTimeDraftTouched.value = false + } + } + + function handleComposerDatePickerOutside(event) { + if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) { + return + } + if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) { + return + } + if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) { + return + } + if (composerDatePickerOpen.value) { + composerDatePickerOpen.value = false + } + if (travelCalculatorOpen.value && !travelCalculatorBusy.value) { + travelCalculatorOpen.value = false + } + } + + async function applyComposerDateSelection() { + if (!composerCanApplyDateSelection.value) { + return + } + + composerBusinessTimeDraftTouched.value = true + composerBusinessTimeTags.value = [ + { + id: `biz-time-${Date.now()}`, + label: buildComposerBusinessTimeLabel() + } + ] + syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext()) + composerDatePickerOpen.value = false + await nextTick() + adjustComposerTextareaHeight() + composerTextareaRef.value?.focus() + } + + function resolveTravelCalculatorInitialDays() { + const businessTimeContext = buildComposerBusinessTimeContext() + if (!businessTimeContext) { + return 1 + } + const startDate = businessTimeContext.start_date + const endDate = businessTimeContext.end_date || startDate + if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { + return 1 + } + const startAt = Date.parse(`${startDate}T00:00:00Z`) + const endAt = Date.parse(`${endDate}T00:00:00Z`) + if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) { + return 1 + } + return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1) + } + + function resolveTravelCalculatorInitialLocation() { + const slotMap = buildReviewSlotMap(activeReviewPayload.value) + const candidates = [ + reviewInlineForm.value.location, + slotMap.business_location?.normalized_value, + slotMap.business_location?.value, + slotMap.location?.normalized_value, + slotMap.location?.value, + currentUser.value?.location + ] + return String(candidates.find((item) => String(item || '').trim()) || '').trim() + } + + function openTravelCalculator() { + closeComposerDatePicker() + travelCalculatorError.value = '' + travelCalculatorResult.value = null + travelCalculatorForm.value = { + days: String(resolveTravelCalculatorInitialDays()), + location: resolveTravelCalculatorInitialLocation() + } + travelCalculatorOpen.value = true + } + + function toggleTravelCalculator() { + if (travelCalculatorOpen.value) { + closeTravelCalculator() + return + } + openTravelCalculator() + } + + function closeTravelCalculator() { + if (travelCalculatorBusy.value) { + return + } + travelCalculatorOpen.value = false + } + + function formatTravelCalculatorMoney(value) { + const amount = Number(value) + if (!Number.isFinite(amount)) { + return String(value || '0') + } + return amount.toFixed(2) + } + + function buildTravelCalculatorResultText(result) { + const days = Number(result?.days) || 1 + const location = String(result?.location || '').trim() || '未填写地点' + const matchedCity = String(result?.matched_city || location).trim() + const grade = String(result?.grade || '').trim() || '当前职级' + const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位' + const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域' + const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则' + const ruleVersion = String(result?.rule_version || '').trim() + const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate) + const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount) + const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate) + const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate) + const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate) + const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount) + const totalAmount = formatTravelCalculatorMoney(result?.total_amount) + const ruleVersionText = ruleVersion ? `(${ruleVersion})` : '' + const user = currentUser.value || {} + const displayName = String(user.name || user.display_name || user.username || '').trim() + const greeting = displayName ? `您好,${displayName},` : '您好,' + + return [ + `${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`, + '', + `**参考可报销合计:${totalAmount} 元**`, + '', + '| 项目 | 标准口径 | 天数 | 小计 |', + '| --- | --- | ---: | ---: |', + `| 住宿费 | ${matchedCity} / ${grade}(${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`, + `| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`, + '', + '**计算过程**', + `1. 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元`, + `2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount} 元`, + `3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount} 元`, + '', + `**规则依据**:${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`, + '', + '这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。' + ].join('\n') + } + + async function submitTravelCalculator() { + if (!travelCalculatorCanSubmit.value) { + travelCalculatorError.value = '请填写出差天数和地点后再计算。' + return + } + + travelCalculatorBusy.value = true + travelCalculatorError.value = '' + try { + const user = currentUser.value || {} + const payload = await calculateTravelReimbursement({ + days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1), + location: String(travelCalculatorForm.value.location || '').trim(), + grade: String(user.grade || '').trim() + }) + travelCalculatorResult.value = payload + messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], { + meta: ['差旅计算器'], + metaTone: 'low' + })) + travelCalculatorOpen.value = false + nextTick(scrollToBottom) + } catch (error) { + travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。' + } finally { + travelCalculatorBusy.value = false + } + } + + return { + composerDatePickerOpen, + composerDateMode, + composerSingleDate, + composerRangeStartDate, + composerRangeEndDate, + composerBusinessTimeTags, + composerBusinessTimeDraftTouched, + composerCanApplyDateSelection, + travelCalculatorOpen, + travelCalculatorBusy, + travelCalculatorError, + travelCalculatorResult, + travelCalculatorForm, + travelCalculatorCanSubmit, + buildComposerBusinessTimeLabel, + hasComposerBusinessTimeSelection, + buildComposerBusinessTimeContext, + mergeBusinessTimeIntoExtraContext, + syncComposerBusinessTimeToReviewCard, + resolveComposerSubmitText, + toggleComposerDatePicker, + closeComposerDatePicker, + setComposerDateMode, + handleComposerDateInputChange, + removeComposerBusinessTimeTag, + handleComposerDatePickerOutside, + applyComposerDateSelection, + resolveTravelCalculatorInitialDays, + resolveTravelCalculatorInitialLocation, + openTravelCalculator, + toggleTravelCalculator, + closeTravelCalculator, + formatTravelCalculatorMoney, + buildTravelCalculatorResultText, + submitTravelCalculator + } +} diff --git a/web/src/views/scripts/useTravelReimbursementFlow.js b/web/src/views/scripts/useTravelReimbursementFlow.js new file mode 100644 index 0000000..7e7ef09 --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementFlow.js @@ -0,0 +1,704 @@ +import { computed, ref } from 'vue' + +function formatFlowDuration(ms) { + const numericValue = Number(ms) + if (!Number.isFinite(numericValue) || numericValue < 0) { + return '--' + } + if (numericValue < 1000) { + return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s` + } + if (numericValue < 10000) { + return `${(numericValue / 1000).toFixed(1)}s` + } + return `${Math.round(numericValue / 1000)}s` +} + +function parseFlowTimestamp(value) { + const timestamp = new Date(value || '').getTime() + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function resolveSemanticPhaseDurations(run) { + const runStart = parseFlowTimestamp(run?.started_at) + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + const firstToolStartedAt = toolCalls + .map((item) => parseFlowTimestamp(item?.created_at)) + .filter((value) => value > 0) + .sort((left, right) => left - right)[0] || 0 + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + const semanticFinishedAt = firstToolStartedAt || runFinishedAt + + if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) { + return { intentMs: null, extractionMs: null } + } + + const totalMs = semanticFinishedAt - runStart + const intentMs = Math.max(120, Math.round(totalMs * 0.35)) + const extractionMs = Math.max(160, totalMs - intentMs) + return { + intentMs, + extractionMs + } +} + +function resolveToolCallDurationMs(toolCall, index, toolCalls, run) { + const explicitDuration = Number(toolCall?.duration_ms) + if (Number.isFinite(explicitDuration) && explicitDuration > 0) { + return explicitDuration + } + + const startedAt = parseFlowTimestamp(toolCall?.created_at) + if (!startedAt) { + return null + } + + const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at) + const runFinishedAt = parseFlowTimestamp(run?.finished_at) + const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0) + + if (!finishedAt || finishedAt <= startedAt) { + return null + } + + return finishedAt - startedAt +} + +export function useTravelReimbursementFlow({ + activeSessionType, + reviewDrawerMode, + insightPanelCollapsed, + isKnowledgeSession, + fetchAgentRunDetail, + buildLocalIntentPreview, + buildLocalExtractionProgressMessages, + summarizeSemanticIntentDetail, + summarizeSemanticParseDetail, + SCENARIO_LABELS, + INTENT_LABELS, + EXPENSE_TYPE_LABELS, + FLOW_STEP_FALLBACKS, + REVIEW_DRAWER_MODE_FLOW, + REVIEW_DRAWER_MODE_REVIEW, + FLOW_STEP_STATUS_PENDING, + FLOW_STEP_STATUS_RUNNING, + FLOW_STEP_STATUS_COMPLETED, + FLOW_STEP_STATUS_FAILED +}) { + const flowRunId = ref('') + const flowStartedAt = ref(0) + const flowFinishedAt = ref(0) + const flowSteps = ref([]) + const flowRefreshBusy = ref(false) + const flowTick = ref(Date.now()) + let flowTickTimer = 0 + const flowSimulationTimers = [] + + const completedFlowStepCount = computed( + () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length + ) + const runningFlowStep = computed( + () => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null + ) + const flowOverallStatusTone = computed(() => { + if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { + return 'failed' + } + if (runningFlowStep.value) { + return 'running' + } + if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) { + return 'completed' + } + return 'pending' + }) + const flowOverallStatusText = computed(() => { + const total = flowSteps.value.length + const completed = completedFlowStepCount.value + if (flowOverallStatusTone.value === 'failed') { + return `异常 ${completed}/${total}` + } + if (flowOverallStatusTone.value === 'completed') { + return `已完成 ${total}/${total}` + } + if (flowOverallStatusTone.value === 'running') { + return `执行中 ${completed}/${total}` + } + return total ? `待执行 0/${total}` : '暂无流程' + }) + const flowTotalDurationText = computed(() => { + if (!flowStartedAt.value) { + return '--' + } + + const finishedAt = flowFinishedAt.value || (runningFlowStep.value ? flowTick.value : 0) + if (finishedAt > flowStartedAt.value) { + return formatFlowDuration(finishedAt - flowStartedAt.value) + } + + const measuredDuration = flowSteps.value.reduce((total, step) => { + const duration = Number(step.durationMs) + return total + (Number.isFinite(duration) && duration > 0 ? duration : 0) + }, 0) + return measuredDuration ? formatFlowDuration(measuredDuration) : '--' + }) + + function startFlowTick() { + if (flowTickTimer) { + return + } + flowTickTimer = window.setInterval(() => { + flowTick.value = Date.now() + }, 250) + } + + function stopFlowRuntime() { + if (flowTickTimer) { + window.clearInterval(flowTickTimer) + flowTickTimer = 0 + } + clearFlowSimulationTimers() + } + + function clearFlowSimulationTimers() { + while (flowSimulationTimers.length) { + const timerId = flowSimulationTimers.pop() + window.clearTimeout(timerId) + window.clearInterval(timerId) + } + } + + function resetFlowRun(options = {}) { + clearFlowSimulationTimers() + const shouldOpenDrawer = options.openDrawer !== false + const startedAt = Number(options.startedAt) + flowRunId.value = '' + flowStartedAt.value = Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : Date.now() + flowFinishedAt.value = 0 + if (shouldOpenDrawer) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW + insightPanelCollapsed.value = false + } + flowSteps.value = [] + } + + function findFlowDefinition(key) { + return FLOW_STEP_FALLBACKS[key] || null + } + + function normalizeFlowStepPatch(key, patch = {}) { + const definition = findFlowDefinition(key) || {} + const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch } + return { + title: normalizedPatch.title || definition.title || '智能体工具调用', + tool: normalizedPatch.tool || definition.tool || 'AgentTool', + detail: normalizedPatch.detail || definition.runningText || '', + ...normalizedPatch + } + } + + function createFlowStep(key, patch = {}) { + const normalizedPatch = normalizeFlowStepPatch(key, patch) + return { + key, + index: flowSteps.value.length + 1, + title: normalizedPatch.title, + tool: normalizedPatch.tool, + status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING, + detail: normalizedPatch.detail || '', + durationMs: normalizedPatch.durationMs ?? null, + startedAt: normalizedPatch.startedAt || 0, + finishedAt: normalizedPatch.finishedAt || 0, + error: normalizedPatch.error || '' + } + } + + function normalizeFlowStepIndexes(steps) { + return steps.map((step, index) => ({ ...step, index: index + 1 })) + } + + function upsertFlowStep(key, patch) { + const existingStep = flowSteps.value.find((step) => step.key === key) + if (!existingStep) { + const nextStep = createFlowStep(key, patch) + flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep]) + return + } + const normalizedPatch = normalizeFlowStepPatch(key, patch) + flowSteps.value = flowSteps.value.map((step) => ( + step.key === key ? { ...step, ...normalizedPatch } : step + )) + } + + function startFlowStep(key, patch = {}) { + const normalizedPatch = normalizeFlowStepPatch(key, patch) + const explicitStartedAt = Number(normalizedPatch.startedAt) + const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0 + ? explicitStartedAt + : Date.now() + upsertFlowStep(key, { + ...normalizedPatch, + status: FLOW_STEP_STATUS_RUNNING, + detail: normalizedPatch.detail, + startedAt, + finishedAt: 0, + durationMs: null, + error: '' + }) + } + + function completeFlowStep(key, detail = '', durationMs = null, patch = {}) { + const now = Date.now() + const definition = findFlowDefinition(key) + const currentStep = flowSteps.value.find((step) => step.key === key) + const explicitDuration = Number(durationMs) + const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0 + const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now) + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_COMPLETED, + detail: detail || definition?.completedText || '', + startedAt, + finishedAt: now, + durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt), + error: '' + }) + } + + function failFlowStep(key, detail = '', error = '', patch = {}) { + const now = Date.now() + const definition = findFlowDefinition(key) + const currentStep = flowSteps.value.find((step) => step.key === key) + const startedAt = currentStep?.startedAt || now + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_FAILED, + detail: detail || error || '调用失败', + startedAt, + finishedAt: now, + durationMs: now - startedAt, + error: String(error || definition?.title || '').trim() + }) + flowFinishedAt.value = now + } + + function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) { + const currentStep = flowSteps.value.find((step) => step.key === key) + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) { + return + } + const normalizedDuration = Number(durationMs) + const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0 + if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) { + if (!hasMeasuredDuration && !currentStep?.startedAt) { + upsertFlowStep(key, { + ...patch, + status: FLOW_STEP_STATUS_COMPLETED, + detail: detail || findFlowDefinition(key)?.completedText || '', + startedAt: 0, + finishedAt: 0, + durationMs: null, + error: '' + }) + return + } + startFlowStep(key, patch) + } + completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch) + } + + function failCurrentFlowStep(error) { + clearFlowSimulationTimers() + const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING) + failFlowStep( + currentStep?.key || 'orchestrator-error', + error?.message || '智能体调用失败', + error?.message || '', + currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' } + ) + } + + function startSemanticFlowPreview(rawText, options = {}) { + clearFlowSimulationTimers() + const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS }) + const extractionMessages = buildLocalExtractionProgressMessages(rawText, options) + + const completeIntentTimer = window.setTimeout(() => { + const currentStep = flowSteps.value.find((step) => step.key === 'intent') + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { + return + } + completePendingFlowStep('intent', intentPreview, null) + }, 260) + flowSimulationTimers.push(completeIntentTimer) + + const startExtractionTimer = window.setTimeout(() => { + const currentStep = flowSteps.value.find((step) => step.key === 'extraction') + if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { + return + } + startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText) + + if (extractionMessages.length <= 1) { + return + } + + let index = 1 + const detailTimer = window.setInterval(() => { + const runningStep = flowSteps.value.find((step) => step.key === 'extraction') + if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) { + window.clearInterval(detailTimer) + return + } + upsertFlowStep('extraction', { + detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1] + }) + index = Math.min(index + 1, extractionMessages.length - 1) + }, 650) + flowSimulationTimers.push(detailTimer) + }, 420) + flowSimulationTimers.push(startExtractionTimer) + } + + function startExpenseSceneSelectionFlowPreview(rawText) { + clearFlowSimulationTimers() + const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS }) + const completeIntentTimer = window.setTimeout(() => { + completePendingFlowStep('intent', intentPreview, null) + }, 220) + flowSimulationTimers.push(completeIntentTimer) + + const startSelectionTimer = window.setTimeout(() => { + startFlowStep('expense-scene-selection', { + detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。' + }) + }, 320) + flowSimulationTimers.push(startSelectionTimer) + } + + function startExpenseIntentConfirmationFlowPreview(rawText) { + clearFlowSimulationTimers() + const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS }) + const completeIntentTimer = window.setTimeout(() => { + completePendingFlowStep('intent', intentPreview, null) + }, 220) + flowSimulationTimers.push(completeIntentTimer) + + const startConfirmationTimer = window.setTimeout(() => { + startFlowStep('expense-intent-confirmation', { + detail: '识别到业务事项描述,但是否发起报销还不明确;暂停信息抽取,等待用户确认。' + }) + }, 320) + flowSimulationTimers.push(startConfirmationTimer) + } + + function startExpenseSceneSelectionAfterIntentConfirmation(rawText) { + clearFlowSimulationTimers() + completePendingFlowStep('expense-intent-confirmation', '用户已确认要发起报销', null) + startFlowStep('expense-scene-selection', { + detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。' + }) + if (reviewDrawerMode.value !== REVIEW_DRAWER_MODE_FLOW) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW + } + } + + function isExpenseSceneSelectionResult(payload) { + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} + if (result.review_payload) { + return false + } + return (Array.isArray(result.suggested_actions) ? result.suggested_actions : []).some( + (item) => String(item?.action_type || '').trim() === 'select_expense_type' + ) + } + + function startReviewActionFlowStep(reviewAction) { + if (reviewAction !== 'next_step') { + return + } + + startFlowStep('pre-submit-review', { + title: 'AI预审与风险识别', + tool: 'ExpenseClaimService.submit_claim', + detail: '正在校验财务规则、风险规则和审批路径...' + }) + } + + function startExpenseClaimDraftFlowStep(reviewAction, options = {}) { + if (isKnowledgeSession.value) { + return + } + if (options.waitForSceneSelection) { + return + } + if (reviewAction === 'next_step') { + startReviewActionFlowStep(reviewAction) + return + } + + const attachmentCount = Math.max(0, Number(options.attachmentCount || 0)) + const configs = { + save_draft: { + key: 'expense-claim-draft', + title: '保存报销草稿', + tool: 'database.expense_claims.save_or_submit', + detail: '正在把已确认信息保存为草稿...' + }, + link_to_existing_draft: { + key: 'expense-claim-draft', + title: '票据关联草稿', + tool: 'database.expense_claims.save_or_submit', + detail: '正在把本次票据关联到现有草稿...' + }, + create_new_claim_from_documents: { + key: 'expense-claim-draft', + title: '新建报销草稿', + tool: 'database.expense_claims.save_or_submit', + detail: '正在根据当前票据新建报销草稿...' + } + } + const config = configs[reviewAction] || { + key: 'expense-review-preview', + title: '报销信息核对', + tool: 'user_agent.expense_review_preview', + detail: attachmentCount + ? '正在根据 OCR 结果整理核对信息...' + : '正在整理识别结果和右侧核对信息...' + } + + startFlowStep(config.key, { + title: config.title, + tool: config.tool, + detail: config.detail + }) + } + + function resolveToolCallFlowMeta(toolCall, index) { + const toolType = String(toolCall?.tool_type || '').toLowerCase() + const toolName = String(toolCall?.tool_name || '').toLowerCase() + const response = toolCall?.response_json && typeof toolCall.response_json === 'object' + ? toolCall.response_json + : {} + const responseMessage = String(response.message || '').trim() + const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}` + if (toolType.includes('rule')) { + return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' } + } + if (toolType.includes('mcp')) { + return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' } + } + if (toolName.includes('knowledge')) { + return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' } + } + if (toolName.includes('expense_review_preview') || response.preview_only) { + return { key: 'expense-review-preview', title: '报销信息核对', tool: toolCall?.tool_name || 'user_agent.expense_review_preview' } + } + if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) { + if ( + response.submission_blocked || + String(response.status || '').trim() === 'submitted' || + responseMessage.includes('AI预审') || + responseMessage.includes('审批') + ) { + return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' } + } + if (responseMessage.includes('关联')) { + return { key: 'expense-claim-draft', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' } + } + if (responseMessage.includes('新建')) { + return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' } + } + return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' } + } + if (toolType.includes('database')) { + return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' } + } + if (toolType.includes('llm') || toolName.includes('user_agent')) { + return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' } + } + return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' } + } + + function summarizeFlowToolCall(toolCall) { + const response = toolCall?.response_json && typeof toolCall.response_json === 'object' + ? toolCall.response_json + : {} + if (String(response.status || '').trim() === 'submitted') { + return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}` + } + if (response.submission_blocked) { + return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批' + } + return ( + String(response.message || response.summary || response.result_summary || '').trim() + || String(toolCall?.tool_name || '').trim() + || '工具调用完成' + ) + } + + function mergeFlowRunDetail(run) { + const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] + if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) { + clearFlowSimulationTimers() + const semanticDurations = resolveSemanticPhaseDurations(run) + const intentStep = flowSteps.value.find((step) => step.key === 'intent') + const extractionStep = flowSteps.value.find((step) => step.key === 'extraction') + completePendingFlowStep( + 'intent', + summarizeSemanticIntentDetail(run.semantic_parse, { + scenarioLabels: SCENARIO_LABELS, + intentLabels: INTENT_LABELS, + expenseTypeLabels: EXPENSE_TYPE_LABELS, + fallbackText: FLOW_STEP_FALLBACKS.intent.completedText + }), + intentStep?.startedAt ? null : semanticDurations.intentMs + ) + completePendingFlowStep( + 'extraction', + summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}), + extractionStep?.startedAt ? null : semanticDurations.extractionMs + ) + } + + toolCalls.forEach((toolCall, index) => { + const meta = resolveToolCallFlowMeta(toolCall, index) + const failed = String(toolCall?.status || '').toLowerCase() === 'failed' + if (failed) { + failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta) + } else { + const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run) + completePendingFlowStep( + meta.key, + summarizeFlowToolCall(toolCall), + toolDurationMs, + meta + ) + } + }) + + if (String(run?.status || '').toLowerCase() === 'failed') { + failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' }) + return + } + } + + function completeFlowResult(payload, run = null) { + const answer = String(payload?.result?.answer || payload?.result?.message || '').trim() + if (!answer && !payload?.result) { + return + } + const sceneSelectionPending = isExpenseSceneSelectionResult(payload) + flowSteps.value + .filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) + .forEach((step) => { + const detail = sceneSelectionPending && step.key === 'expense-scene-selection' + ? '已暂停后续识别,请先在主对话中选择报销场景。' + : resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED }) + completeFlowStep(step.key, detail) + }) + flowFinishedAt.value = Date.now() + if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && !sceneSelectionPending) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + } + + async function refreshFlowRunDetail() { + if (!flowRunId.value || flowRefreshBusy.value) { + return null + } + flowRefreshBusy.value = true + try { + const run = await fetchAgentRunDetail(flowRunId.value) + mergeFlowRunDetail(run) + return run + } catch (error) { + console.warn('Failed to refresh agent run detail:', error) + return null + } finally { + flowRefreshBusy.value = false + } + } + + function formatFlowStepDuration(step) { + if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) { + return formatFlowDuration(flowTick.value - step.startedAt) + } + return formatFlowDuration(step?.durationMs) + } + + function resolveFlowStepStatusLabel(step) { + const status = String(step?.status || '').trim() + if (status === FLOW_STEP_STATUS_COMPLETED) { + return '完成' + } + if (status === FLOW_STEP_STATUS_RUNNING) { + return '执行中' + } + if (status === FLOW_STEP_STATUS_FAILED) { + return '异常' + } + return '待执行' + } + + function resolveFlowStepDetail(step) { + const detail = String(step?.detail || '').trim() + if (detail) { + return detail + } + const definition = findFlowDefinition(step?.key) + if (step?.status === FLOW_STEP_STATUS_COMPLETED) { + return definition?.completedText || '步骤已完成' + } + if (step?.status === FLOW_STEP_STATUS_RUNNING) { + return definition?.runningText || '正在执行当前步骤...' + } + if (step?.status === FLOW_STEP_STATUS_FAILED) { + return step?.error || '步骤执行异常' + } + return definition?.runningText ? `等待${definition.title || '当前步骤'}...` : '等待智能体调度...' + } + + return { + flowRunId, + flowStartedAt, + flowFinishedAt, + flowSteps, + flowRefreshBusy, + flowTick, + completedFlowStepCount, + runningFlowStep, + flowOverallStatusTone, + flowOverallStatusText, + flowTotalDurationText, + clearFlowSimulationTimers, + resetFlowRun, + findFlowDefinition, + normalizeFlowStepPatch, + createFlowStep, + normalizeFlowStepIndexes, + upsertFlowStep, + startFlowTick, + stopFlowRuntime, + startFlowStep, + completeFlowStep, + failFlowStep, + completePendingFlowStep, + failCurrentFlowStep, + startSemanticFlowPreview, + startExpenseSceneSelectionFlowPreview, + startExpenseIntentConfirmationFlowPreview, + startExpenseSceneSelectionAfterIntentConfirmation, + isExpenseSceneSelectionResult, + startReviewActionFlowStep, + startExpenseClaimDraftFlowStep, + resolveToolCallFlowMeta, + summarizeFlowToolCall, + mergeFlowRunDetail, + completeFlowResult, + refreshFlowRunDetail, + formatFlowStepDuration, + resolveFlowStepStatusLabel, + resolveFlowStepDetail + } +} diff --git a/web/src/views/scripts/useTravelReimbursementReviewActions.js b/web/src/views/scripts/useTravelReimbursementReviewActions.js new file mode 100644 index 0000000..270a29d --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementReviewActions.js @@ -0,0 +1,252 @@ +export function useTravelReimbursementReviewActions(ctx) { + const { + activeReviewPayload, + buildDraftSavedPayload, + buildLocalReviewCompletionMessage, + buildLocalReviewSavedMessage, + buildReviewCorrectionMessage, + buildReviewDocumentCorrectionContext, + buildReviewDocumentCorrectionMessage, + buildReviewFormValues, + buildReviewRiskItems, + buildReviewSubmitUserText, + buildLocallySyncedReviewPayload, + cloneReviewDocumentDrafts, + cloneReviewEditFields, + commitInlineReviewEditor, + createMessage, + currentInsight, + currentUser, + emit, + latestReviewMessage, + linkedRequest, + mergeInlineReviewFields, + messages, + nextTick, + reviewActionBusy, + reviewDocumentBaseDrafts, + reviewDocumentDrafts, + reviewHasUnsavedChanges, + reviewInlineBaseFields, + reviewInlineBaseForm, + reviewInlineEditorKey, + reviewInlineForm, + reviewInlinePendingFiles, + scrollToBottom, + sessionSwitchBusy, + submitComposer, + submitting + } = ctx + function saveInlineReviewChanges() { + if ( + !activeReviewPayload.value + || !reviewHasUnsavedChanges.value + || submitting.value + || reviewActionBusy.value + || sessionSwitchBusy.value + ) return + + if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { + return + } + + reviewActionBusy.value = true + try { + const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) + const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value) + const messageText = `${buildLocalReviewSavedMessage( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value, + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + )} ${buildLocalReviewCompletionMessage(nextReviewPayload)}` + + reviewInlineBaseFields.value = cloneReviewEditFields(fields) + reviewInlineBaseForm.value = { ...reviewInlineForm.value } + reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value) + if (latestReviewMessage.value) { + latestReviewMessage.value.reviewPayload = nextReviewPayload + } + if (currentInsight.value?.agent) { + currentInsight.value = { + ...currentInsight.value, + agent: { + ...currentInsight.value.agent, + reviewPayload: nextReviewPayload + } + } + } + messages.value.push(createMessage('assistant', messageText, [], { + meta: ['本地修改'], + draftPayload: latestReviewMessage.value?.draftPayload || null, + reviewPayload: nextReviewPayload + })) + nextTick(scrollToBottom) + } finally { + reviewActionBusy.value = false + } + } + + + async function handleReviewAction(message, action) { + const actionType = String(action?.action_type || '').trim() + if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return + + if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { + return + } + + if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { + return + } + + if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { + await handleSaveDraftDirectly(message, actionType) + return + } + + reviewActionBusy.value = true + try { + const baseFields = reviewInlineBaseFields.value.length + ? reviewInlineBaseFields.value + : cloneReviewEditFields(message?.reviewPayload?.edit_fields) + const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) + const reviewChangedUserText = reviewHasUnsavedChanges.value + ? buildReviewSubmitUserText( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value, + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ) + : '' + const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ) + const payload = await submitComposer({ + rawText: [ + reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '', + reviewHasUnsavedChanges.value ? documentCorrectionMessage : '', + '我已核对右侧识别结果,请进入下一步。' + ] + .filter(Boolean) + .join('\n'), + userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。', + files: reviewInlinePendingFiles.value, + pendingText: '正在进入下一步...', + systemGenerated: true, + extraContext: { + review_action: actionType, + review_form_values: buildReviewFormValues(fields), + review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) + } + }) + + if (payload?.result?.draft_payload?.status === 'submitted') { + emit( + 'draft-saved', + buildDraftSavedPayload({ + draftPayload: payload.result.draft_payload, + reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, + inlineState: reviewInlineForm.value, + linkedRequest: linkedRequest.value, + currentUser: currentUser.value, + riskItems: buildReviewRiskItems(payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value) + }) + ) + } + } finally { + reviewActionBusy.value = false + } + } + + async function handleSaveDraftDirectly(message, actionType = 'save_draft') { + reviewActionBusy.value = true + + const actionConfig = { + save_draft: { + rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', + pendingText: '正在保存当前草稿...', + successMeta: '草稿已保存', + successMessage: (payload) => { + const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() + return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成' + } + }, + link_to_existing_draft: { + rawText: '请把当前上传的票据合并到现有报销草稿中。', + pendingText: '正在关联到现有草稿...', + successMeta: '已关联草稿', + successMessage: (payload) => { + const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() + return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿' + } + }, + create_new_claim_from_documents: { + rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。', + pendingText: '正在建立新的报销草稿...', + successMeta: '新草稿已建立', + successMessage: (payload) => { + const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() + return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿' + } + } + }[actionType] || { + rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', + pendingText: '正在保存当前草稿...', + successMeta: '草稿已保存', + successMessage: () => '草稿保存完成' + } + + try { + const baseFields = reviewInlineBaseFields.value.length + ? reviewInlineBaseFields.value + : cloneReviewEditFields(message?.reviewPayload?.edit_fields) + const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) + + const payload = await submitComposer({ + rawText: actionConfig.rawText, + userText: '', + skipUserMessage: true, + files: reviewInlinePendingFiles.value, + pendingText: actionConfig.pendingText, + systemGenerated: true, + extraContext: { + review_action: actionType, + review_form_values: buildReviewFormValues(fields), + review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) + } + }) + + if (payload?.result?.draft_payload?.claim_no) { + emit( + 'draft-saved', + buildDraftSavedPayload({ + draftPayload: payload.result.draft_payload, + reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, + inlineState: reviewInlineForm.value, + linkedRequest: linkedRequest.value, + currentUser: currentUser.value, + riskItems: buildReviewRiskItems(payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value) + }) + ) + } + + nextTick(scrollToBottom) + } catch (error) { + messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] })) + nextTick(scrollToBottom) + } finally { + reviewActionBusy.value = false + } + } + + + return { + handleReviewActionInternal: handleReviewAction, + handleSaveDraftDirectlyInternal: handleSaveDraftDirectly, + saveInlineReviewChangesInternal: saveInlineReviewChanges + } +} diff --git a/web/src/views/scripts/useTravelReimbursementReviewDrawer.js b/web/src/views/scripts/useTravelReimbursementReviewDrawer.js new file mode 100644 index 0000000..ee9c647 --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementReviewDrawer.js @@ -0,0 +1,422 @@ +import { computed, ref } from 'vue' + +import { + DATE_INPUT_FORMAT, + REVIEW_CATEGORY_PRESET_OPTIONS, + REVIEW_OTHER_CATEGORY_OPTIONS, + REVIEW_SCENE_OTHER_OPTION, + buildInlineReviewChangedLines, + buildInlineReviewState, + buildReviewCategoryOptions, + buildReviewDocumentDrafts, + buildReviewDocumentSummaries, + buildReviewPanelConfidence, + buildReviewRecognitionNotes, + buildReviewRecognizedLines, + cloneReviewDocumentDrafts, + cloneReviewEditFields, + createEmptyInlineReviewState, + extractAmountInputValue, + formatConfidenceLabel, + isValidIsoDateString, + normalizeAmountValue, + normalizeReviewDocumentComparableValue, + resolveReviewCategoryConfidenceScore +} from './travelReimbursementReviewModel.js' + +export function useTravelReimbursementReviewDrawer({ + activeReviewPayload, + reviewFilePreviews, + flowSteps, + submitting, + reviewActionBusy, + triggerFileUpload, + resolveDocumentPreview, + buildReviewFactCards, + buildReviewRiskItems, + buildReviewRiskSummary, + buildReviewIntentText, + resolveReviewRiskBriefs, + reviewDrawerMode: externalReviewDrawerMode, + REVIEW_DRAWER_MODE_REVIEW, + REVIEW_DRAWER_MODE_DOCUMENTS, + REVIEW_DRAWER_MODE_RISK, + REVIEW_DRAWER_MODE_FLOW +}) { + const reviewInlineForm = ref(createEmptyInlineReviewState()) + const reviewInlineBaseForm = ref(createEmptyInlineReviewState()) + const reviewInlineBaseFields = ref([]) + const reviewInlinePendingFiles = ref([]) + const reviewInlineEditorKey = ref('') + const reviewInlineErrors = ref({}) + const reviewOtherCategoryOpen = ref(false) + const reviewDocumentDrafts = ref([]) + const reviewDocumentBaseDrafts = ref([]) + const activeReviewDocumentIndex = ref(0) + const reviewDrawerMode = externalReviewDrawerMode || ref(REVIEW_DRAWER_MODE_REVIEW) + const documentPreviewDialog = ref({ + open: false, + filename: '', + kind: 'file', + url: '' + }) + + const activeReviewFilePreviews = computed(() => reviewFilePreviews.value) + const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) + const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) + const reviewCategoryOptions = computed(() => + buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value) + ) + const reviewOtherCategoryOptions = computed(() => + REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({ + ...item, + confidenceLabel: formatConfidenceLabel( + resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value) + ) + })) + ) + const reviewSelectedOtherCategory = computed(() => { + const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) + return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type + }) + const reviewInlineDirty = computed( + () => + buildInlineReviewChangedLines( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value + ).length > 0 + ) + const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) + const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) + const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) + const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length) + const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length) + const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0) + const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value) + const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0) + const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value)) + const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value)) + const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value)) + const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) + const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) + const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) + const reviewDrawerTitle = computed(() => ( + isReviewDocumentDrawer.value + ? '绁ㄦ嵁璇嗗埆缁撴灉' + : isReviewRiskDrawer.value + ? '椋庨櫓鎻愮ず' + : isReviewFlowDrawer.value + ? '璋冪敤娴佺▼' + : '鎶ラ攢璇嗗埆鏍稿' + )) + const reviewDocumentDrawerLabel = computed(() => ( + '鍗曟嵁璇嗗埆' + )) + const reviewDocumentDrawerIcon = computed(() => ( + isReviewDocumentDrawer.value + ? 'mdi mdi-file-document-multiple' + : 'mdi mdi-file-document-multiple-outline' + )) + const reviewRiskDrawerLabel = computed(() => ( + '鏄剧ず椋庨櫓' + )) + const reviewRiskDrawerIcon = computed(() => ( + isReviewRiskDrawer.value + ? 'mdi mdi-shield-alert' + : 'mdi mdi-shield-alert-outline' + )) + const reviewFlowDrawerLabel = computed(() => ( + '璋冪敤娴佺▼' + )) + const reviewFlowDrawerIcon = computed(() => ( + isReviewFlowDrawer.value + ? 'mdi mdi-timeline-clock' + : 'mdi mdi-timeline-clock-outline' + )) + const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null) + const activeReviewDocumentPreview = computed(() => + activeReviewDocument.value + ? ( + resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename) + || ( + activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url + ? { + filename: activeReviewDocument.value.filename, + kind: activeReviewDocument.value.preview_kind, + url: activeReviewDocument.value.preview_data_url + } + : null + ) + ) + : null + ) + const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url)) + const reviewDocumentDirty = computed(() => { + const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue)) + const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue)) + return baseValue !== nextValue + }) + const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value) + + function resetReviewDrawerFromPayload(payload) { + const normalizedInlineState = buildInlineReviewState(payload) + reviewInlineForm.value = { ...normalizedInlineState } + reviewInlineBaseForm.value = { ...normalizedInlineState } + reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) + const nextDocumentDrafts = buildReviewDocumentDrafts(payload) + reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) + reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) + activeReviewDocumentIndex.value = nextDocumentDrafts.length + ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) + : 0 + reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length + ? REVIEW_DRAWER_MODE_RISK + : REVIEW_DRAWER_MODE_REVIEW + reviewInlinePendingFiles.value = [] + reviewInlineEditorKey.value = '' + reviewInlineErrors.value = {} + reviewOtherCategoryOpen.value = false + } + + function setInlineReviewFieldError(key, message) { + reviewInlineErrors.value = { + ...reviewInlineErrors.value, + [key]: String(message || '').trim() + } + } + + function clearInlineReviewFieldError(key) { + if (!reviewInlineErrors.value[key]) { + return + } + + const nextErrors = { ...reviewInlineErrors.value } + delete nextErrors[key] + reviewInlineErrors.value = nextErrors + } + + function openInlineReviewEditor(key) { + if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return + if (key === 'attachments') { + triggerFileUpload('inline-review') + return + } + + if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) { + return + } + + if (reviewInlineEditorKey.value === key) { + commitInlineReviewEditor() + return + } + + if (key === 'amount') { + reviewInlineForm.value = { + ...reviewInlineForm.value, + amount: extractAmountInputValue(reviewInlineForm.value.amount) + } + } + + clearInlineReviewFieldError(key) + reviewInlineEditorKey.value = key + if (key !== 'expense_type') { + reviewOtherCategoryOpen.value = false + } + } + + function closeInlineReviewEditor() { + reviewInlineEditorKey.value = '' + reviewOtherCategoryOpen.value = false + } + + function commitInlineReviewEditor() { + const activeEditorKey = reviewInlineEditorKey.value + const nextForm = { + ...reviewInlineForm.value, + occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), + amount: String(reviewInlineForm.value.amount || '').trim(), + transport_type: String(reviewInlineForm.value.transport_type || '').trim(), + customer_name: String(reviewInlineForm.value.customer_name || '').trim(), + location: String(reviewInlineForm.value.location || '').trim(), + merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), + participants: String(reviewInlineForm.value.participants || '').trim(), + scene_label: String(reviewInlineForm.value.scene_label || '').trim(), + reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(), + expense_type: String(reviewInlineForm.value.expense_type || '').trim() + } + + if ( + activeEditorKey === 'scene' && + nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION + ) { + nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim() + if (!nextForm.reason_value) { + setInlineReviewFieldError('scene', '璇烽€夋嫨鈥滃叾浠栧満鏅€濆悗锛岃琛ュ厖鍏蜂綋浜嬬敱') + reviewInlineForm.value = nextForm + return false + } + } else if (activeEditorKey === 'scene') { + nextForm.reason_value = nextForm.scene_label + } + + if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) { + setInlineReviewFieldError('occurred_date', `璇疯緭鍏ユ纭殑鏃堕棿鏍煎紡锛?{DATE_INPUT_FORMAT}`) + return false + } + + if (activeEditorKey === 'amount' && nextForm.amount) { + const normalizedAmount = normalizeAmountValue(nextForm.amount) + if (!normalizedAmount) { + setInlineReviewFieldError('amount', '璇疯緭鍏ユ纭殑鏁板瓧閲戦锛屼緥濡?200 鎴?200.50') + return false + } + nextForm.amount = normalizedAmount + } + + if (activeEditorKey) { + clearInlineReviewFieldError(activeEditorKey) + } + + reviewInlineForm.value = nextForm + reviewInlineEditorKey.value = '' + return true + } + + function selectInlineScene(scene) { + const normalizedScene = String(scene || '').trim() + reviewInlineForm.value = { + ...reviewInlineForm.value, + scene_label: normalizedScene, + reason_value: + normalizedScene === REVIEW_SCENE_OTHER_OPTION + ? '' + : normalizedScene + } + clearInlineReviewFieldError('scene') + if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) { + reviewInlineEditorKey.value = '' + } + } + + function selectReviewCategory(option) { + if (!option) return + if (option.is_other) { + reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value + return + } + + reviewInlineForm.value = { + ...reviewInlineForm.value, + expense_type: option.label + } + reviewOtherCategoryOpen.value = false + } + + function selectReviewOtherCategory(option) { + if (!option) return + reviewInlineForm.value = { + ...reviewInlineForm.value, + expense_type: option.label + } + reviewOtherCategoryOpen.value = false + } + + function goReviewDocument(direction) { + const total = reviewDocumentCount.value + if (!total) return + const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0) + activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex)) + } + + function openActiveReviewDocumentPreview() { + if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return + documentPreviewDialog.value = { + open: true, + filename: activeReviewDocument.value.filename, + kind: activeReviewDocumentPreview.value.kind, + url: activeReviewDocumentPreview.value.url + } + } + + function closeDocumentPreview() { + documentPreviewDialog.value = { + ...documentPreviewDialog.value, + open: false + } + } + + function enforceReviewDrawerAvailability() { + if (!reviewDocumentDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + if (!reviewRiskDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + if (!reviewFlowDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) { + reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + } + } + + return { + reviewInlineForm, + reviewInlineBaseForm, + reviewInlineBaseFields, + reviewInlinePendingFiles, + reviewInlineEditorKey, + reviewInlineErrors, + reviewOtherCategoryOpen, + reviewDocumentDrafts, + reviewDocumentBaseDrafts, + activeReviewDocumentIndex, + reviewDrawerMode, + documentPreviewDialog, + activeReviewFilePreviews, + reviewIntentText, + reviewFactCards, + reviewCategoryOptions, + reviewOtherCategoryOptions, + reviewSelectedOtherCategory, + reviewInlineDirty, + reviewPanelConfidence, + reviewRiskSummary, + reviewRiskItems, + reviewRiskEmpty, + reviewDocumentDrawerAvailable, + reviewRiskDrawerAvailable, + reviewFlowDrawerAvailable, + recognizedNarratives, + reviewRecognitionNotes, + reviewDocumentSummaries, + reviewDocumentCount, + isReviewDocumentDrawer, + isReviewRiskDrawer, + isReviewFlowDrawer, + reviewDrawerTitle, + reviewDocumentDrawerLabel, + reviewDocumentDrawerIcon, + reviewRiskDrawerLabel, + reviewRiskDrawerIcon, + reviewFlowDrawerLabel, + reviewFlowDrawerIcon, + activeReviewDocument, + activeReviewDocumentPreview, + canPreviewActiveReviewDocument, + reviewDocumentDirty, + reviewHasUnsavedChanges, + setInlineReviewFieldError, + clearInlineReviewFieldError, + resetReviewDrawerFromPayload, + openInlineReviewEditor, + closeInlineReviewEditor, + commitInlineReviewEditor, + selectInlineScene, + selectReviewCategory, + selectReviewOtherCategory, + goReviewDocument, + openActiveReviewDocumentPreview, + closeDocumentPreview, + enforceReviewDrawerAvailability + } +} diff --git a/web/src/views/scripts/useTravelReimbursementSessionState.js b/web/src/views/scripts/useTravelReimbursementSessionState.js new file mode 100644 index 0000000..ae4d62a --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementSessionState.js @@ -0,0 +1,335 @@ +import { nextTick, ref } from 'vue' + +import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js' +import { + clearAssistantSessionSnapshot, + readAssistantSessionSnapshot, + writeAssistantSessionSnapshot +} from '../../utils/assistantSessionSnapshot.js' +import { buildReviewFilePreviewsFromMessages } from './travelReimbursementAttachmentModel.js' +import { + SESSION_TYPE_EXPENSE, + SESSION_TYPE_KNOWLEDGE, + buildInitialInsightFromConversation, + buildWelcomeInsight, + createWelcomeAssistantMessage, + hasMeaningfulSessionMessages, + normalizeInitialConversationMessages, + normalizeSnapshotMessages, + resolveInitialConversationId, + resolveInitialDraftClaimId, + resolveInitialSessionType, + serializeSessionMessages, + shouldPreferPersistedSessionState +} from './travelReimbursementConversationModel.js' + +export function useTravelReimbursementSessionState({ + props, + currentUser, + linkedRequest, + toast, + composerDraft, + uploadDecisionDialogOpen, + adjustComposerTextareaHeight, + scrollToBottom, + getSessionRuntimeRefs = () => ({}) +}) { + function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) { + const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType + const restoredMessages = normalizeInitialConversationMessages(conversation) + const initialInsight = buildInitialInsightFromConversation(conversation) + const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages) + + return { + sessionType, + messages: restoredMessages.length + ? restoredMessages + : [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)], + conversationId: resolveInitialConversationId(conversation), + draftClaimId: resolveInitialDraftClaimId(conversation), + currentInsight: + initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), + reviewFilePreviews: restoredReviewFilePreviews, + composerDraft: '', + attachedFiles: [], + composerFilesExpanded: false, + composerUploadIntent: '', + insightPanelCollapsed: false + } + } + + function buildEmptySessionState(sessionType) { + return { + sessionType, + messages: [ + createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value) + ], + conversationId: '', + draftClaimId: '', + currentInsight: buildWelcomeInsight( + props.entrySource, + linkedRequest.value, + sessionType, + currentUser.value + ), + reviewFilePreviews: [], + composerDraft: '', + attachedFiles: [], + composerFilesExpanded: false, + composerUploadIntent: '', + insightPanelCollapsed: false + } + } + + function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) { + const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null + if (!state) { + return null + } + + const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE + const restoredMessages = normalizeSnapshotMessages(state.messages) + if ( + !hasMeaningfulSessionMessages(restoredMessages) + && !String(state.conversationId || '').trim() + && !String(state.draftClaimId || '').trim() + ) { + return null + } + + return { + sessionType, + messages: restoredMessages.length + ? restoredMessages + : [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)], + conversationId: String(state.conversationId || '').trim(), + draftClaimId: String(state.draftClaimId || '').trim(), + currentInsight: + state.currentInsight + || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), + reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [], + composerDraft: String(state.composerDraft || ''), + attachedFiles: [], + composerFilesExpanded: false, + composerUploadIntent: String(state.composerUploadIntent || '').trim(), + insightPanelCollapsed: Boolean(state.insightPanelCollapsed) + } + } + + function resolveCurrentUserId() { + const user = currentUser.value || {} + return String(user.username || user.name || 'anonymous').trim() || 'anonymous' + } + + const initialSessionType = resolveInitialSessionType(props.initialConversation) + const conversationInitialState = props.initialConversation + ? buildConversationSessionState(props.initialConversation, initialSessionType) + : buildEmptySessionState(initialSessionType) + const canRestorePersistedInitialState = + props.entrySource === 'workbench' + && !String(props.initialPrompt || '').trim() + && !props.initialFiles.length + const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType) + const persistedInitialState = canRestorePersistedInitialState + ? buildPersistedSessionState(persistedInitialSnapshot, initialSessionType) + : null + const initialSessionState = canRestorePersistedInitialState && shouldPreferPersistedSessionState( + persistedInitialState, + persistedInitialSnapshot, + props.initialConversation + ) + ? persistedInitialState + : conversationInitialState + + const activeSessionType = ref(initialSessionState.sessionType) + const messages = ref(initialSessionState.messages) + const conversationId = ref(initialSessionState.conversationId) + const draftClaimId = ref(initialSessionState.draftClaimId) + const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) + const sessionSnapshots = ref({ + [SESSION_TYPE_EXPENSE]: null, + [SESSION_TYPE_KNOWLEDGE]: null + }) + const currentInsight = ref(initialSessionState.currentInsight) + const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim()) + const insightPanelCollapsed = ref(false) + const sessionSwitchBusy = ref(false) + let knowledgeSessionResetPromise = Promise.resolve() + + function buildPersistableSessionState(sessionState) { + const state = sessionState || captureCurrentSessionState() + return { + sessionType: state.sessionType || SESSION_TYPE_EXPENSE, + messages: serializeSessionMessages(state.messages), + conversationId: String(state.conversationId || '').trim(), + draftClaimId: String(state.draftClaimId || '').trim(), + currentInsight: state.currentInsight || null, + reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [], + composerDraft: String(state.composerDraft || ''), + composerUploadIntent: String(state.composerUploadIntent || '').trim(), + insightPanelCollapsed: Boolean(state.insightPanelCollapsed) + } + } + + function persistSessionState(sessionState = null) { + const state = sessionState || captureCurrentSessionState() + const persistedState = buildPersistableSessionState(state) + const meaningful = Boolean( + String(persistedState.conversationId || '').trim() + || String(persistedState.draftClaimId || '').trim() + || hasMeaningfulSessionMessages(persistedState.messages) + || String(persistedState.composerDraft || '').trim() + ) + + if (!meaningful) { + clearAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType) + return + } + + writeAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType, persistedState) + } + + function captureCurrentSessionState() { + const runtimeRefs = getSessionRuntimeRefs() + return { + sessionType: activeSessionType.value, + messages: messages.value, + conversationId: conversationId.value, + draftClaimId: draftClaimId.value, + currentInsight: currentInsight.value, + reviewFilePreviews: reviewFilePreviews.value, + composerDraft: composerDraft.value, + attachedFiles: runtimeRefs.attachedFiles?.value ?? [], + composerFilesExpanded: runtimeRefs.composerFilesExpanded?.value ?? false, + composerUploadIntent: composerUploadIntent.value, + insightPanelCollapsed: insightPanelCollapsed.value + } + } + + function applySessionState(sessionState) { + const runtimeRefs = getSessionRuntimeRefs() + const nextState = sessionState || buildEmptySessionState(activeSessionType.value) + activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE + messages.value = Array.isArray(nextState.messages) && nextState.messages.length + ? nextState.messages + : [ + createWelcomeAssistantMessage( + props.entrySource, + linkedRequest.value, + activeSessionType.value, + currentUser.value + ) + ] + conversationId.value = String(nextState.conversationId || '').trim() + draftClaimId.value = String(nextState.draftClaimId || '').trim() + currentInsight.value = + nextState.currentInsight + || buildWelcomeInsight( + props.entrySource, + linkedRequest.value, + activeSessionType.value, + currentUser.value + ) + reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : [] + composerDraft.value = String(nextState.composerDraft || '') + if (runtimeRefs.attachedFiles) { + runtimeRefs.attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : [] + } + if (runtimeRefs.composerFilesExpanded) { + runtimeRefs.composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded) + } + composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim() + insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed) + uploadDecisionDialogOpen.value = false + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + } + + async function loadLatestSessionState(targetSessionType) { + const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, { + preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE + }) + if (payload?.found && payload.conversation) { + return buildConversationSessionState(payload.conversation, targetSessionType) + } + return buildEmptySessionState(targetSessionType) + } + + function resetKnowledgeSessionSnapshot() { + const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE) + sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState + + if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) { + applySessionState(emptyKnowledgeState) + } + } + + function clearKnowledgeSessionOnEntry() { + resetKnowledgeSessionSnapshot() + knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE) + .catch((error) => { + console.warn('Failed to clear knowledge session on entry:', error) + }) + .finally(() => { + resetKnowledgeSessionSnapshot() + }) + return knowledgeSessionResetPromise + } + + async function switchSessionType(targetSessionType) { + const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE + if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) { + return + } + + sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState() + if (sessionSnapshots.value[normalizedTarget]) { + applySessionState(sessionSnapshots.value[normalizedTarget]) + return + } + + sessionSwitchBusy.value = true + try { + const nextState = await loadLatestSessionState(normalizedTarget) + sessionSnapshots.value[normalizedTarget] = nextState + applySessionState(nextState) + } catch (error) { + const emptyState = buildEmptySessionState(normalizedTarget) + sessionSnapshots.value[normalizedTarget] = emptyState + applySessionState(emptyState) + toast(error?.message || '?????????????????') + } finally { + sessionSwitchBusy.value = false + } + } + + sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState() + + return { + activeSessionType, + messages, + conversationId, + draftClaimId, + sessionSnapshots, + currentInsight, + reviewFilePreviews, + composerUploadIntent, + insightPanelCollapsed, + sessionSwitchBusy, + initialSessionState, + buildConversationSessionState, + buildEmptySessionState, + buildPersistedSessionState, + resolveCurrentUserId, + buildPersistableSessionState, + persistSessionState, + captureCurrentSessionState, + applySessionState, + loadLatestSessionState, + resetKnowledgeSessionSnapshot, + clearKnowledgeSessionOnEntry, + switchSessionType + } +} diff --git a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js new file mode 100644 index 0000000..fe91149 --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js @@ -0,0 +1,474 @@ +export function useTravelReimbursementSubmitComposer(ctx) { + const { + MAX_ATTACHMENTS, + activeReviewPayload, + activeSessionType, + adjustComposerTextareaHeight, + attachedFiles, + buildAgentInsight, + buildClientTimeContext, + buildComposerBusinessTimeContext, + buildComposerFilePreviews, + buildDraftAssociationQueryPayload, + buildErrorInsight, + buildExpenseIntentConfirmationActions, + buildExpenseIntentConfirmationMessage, + buildExpenseSceneSelectionActions, + buildExpenseSceneSelectionMessage, + buildMessageMeta, + buildOcrDocumentsFromReviewPayload, + buildOcrFilePreviews, + buildOcrSummary, + buildOcrSummaryFromDocuments, + buildReviewFormContextFromPayload, + clearAttachedFiles, + clearFlowSimulationTimers, + completeFlowResult, + completeFlowStep, + composerBusinessTimeDraftTouched, + composerBusinessTimeTags, + composerDraft, + composerUploadIntent, + conversationId, + createMessage, + currentInsight, + currentUser, + draftClaimId, + extractReviewAttachmentNames, + failCurrentFlowStep, + fetchExpenseClaims, + fileInputRef, + flowRunId, + isKnowledgeSession, + linkedRequest, + mergeBusinessTimeIntoExtraContext, + mergeFilePreviews, + mergeFilesWithLimit, + mergeUploadAttachmentNames, + mergeUploadOcrDocuments, + messages, + nextTick, + normalizeExpenseQueryPayload, + normalizeOcrDocuments, + persistSessionState, + props, + recognizeOcrFiles, + refreshFlowRunDetail, + rememberFilePreviews, + replaceMessage, + resetFlowRun, + resolveComposerSubmitText, + reviewInlineForm, + runOrchestrator, + scrollToBottom, + sessionSwitchBusy, + shouldRequestExpenseIntentConfirmation, + shouldRequestExpenseSceneSelection, + startExpenseClaimDraftFlowStep, + startExpenseIntentConfirmationFlowPreview, + startExpenseSceneSelectionFlowPreview, + startFlowStep, + startSemanticFlowPreview, + submitting, + syncComposerFilesToDraft, + uploadDecisionDialogOpen, + toast + } = ctx + function buildBackendMessage(rawText, fileNames, ocrSummary = '') { + const parts = [] + const normalizedText = String(rawText || '').trim() + + if (normalizedText) { + parts.push(normalizedText) + } else if (fileNames.length) { + parts.push( + isKnowledgeSession.value + ? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。` + : `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。` + ) + } + + if (fileNames.length) { + parts.push(`附件名称:${fileNames.join('、')}`) + } + + if (ocrSummary) { + parts.push(`OCR摘要:${ocrSummary}`) + } + + if (props.entrySource === 'detail' && linkedRequest.value?.id) { + parts.push(`关联单号:${linkedRequest.value.id}`) + } + + return parts.join('\n') + } + + async function submitComposer(options = {}) { + if (submitting.value || sessionSwitchBusy.value) return null + + const rawText = resolveComposerSubmitText(options.rawText).trim() + const systemGenerated = Boolean(options.systemGenerated) + const resolvedUploadDisposition = + String(options.uploadDisposition || '').trim() || + (composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') + const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value) + const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) + const files = fileMergeResult.files + if (fileMergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } + if (!rawText && !files.length) return + const fileNames = files.map((file) => file.name) + + const initialExtraContext = options.extraContext && typeof options.extraContext === 'object' + ? { ...options.extraContext } + : {} + const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext() + const extraContext = isKnowledgeSession.value + ? initialExtraContext + : mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext) + const reviewAction = String(extraContext.review_action || '').trim() + const hasSelectedExpenseType = Boolean( + extraContext.expense_scene_selection || + String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim() + ) + const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed) + const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, { + sessionType: activeSessionType.value, + attachmentCount: files.length, + reviewAction, + hasSelectedExpenseType, + hasConfirmedExpenseIntent + }) + const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, { + sessionType: activeSessionType.value, + attachmentCount: files.length, + reviewAction, + hasSelectedExpenseType + }) + const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) + const hasExistingDocumentEvent = + Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 + const userText = + String(options.userText || '').trim() || + rawText || + (isKnowledgeSession.value + ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` + : resolvedUploadDisposition === 'continue_existing' + ? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。` + : resolvedUploadDisposition === 'new_document' + ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` + : `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`) + + if ( + !isKnowledgeSession.value && + files.length && + hasExistingDocumentEvent && + !resolvedUploadDisposition && + !options.skipUploadDecisionPrompt && + !reviewAction + ) { + uploadDecisionDialogOpen.value = true + return null + } + + if ( + !isKnowledgeSession.value && + files.length && + !hasExistingDocumentEvent && + !resolvedUploadDisposition && + !options.skipDraftAssociationPrompt && + !reviewAction + ) { + try { + const claims = await fetchExpenseClaims() + const queryPayload = buildDraftAssociationQueryPayload(claims) + if (queryPayload?.records?.length) { + resetFlowRun() + if (!options.skipUserMessage) { + messages.value.push(createMessage('user', userText, fileNames)) + } + messages.value.push(createMessage( + 'assistant', + `我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`, + [], + { + meta: ['等待选择关联单据'], + queryPayload + } + )) + composerDraft.value = '' + composerBusinessTimeTags.value = [] + composerBusinessTimeDraftTouched.value = false + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + persistSessionState() + return null + } + } catch (error) { + console.warn('Failed to load draft claims before attachment recognition:', error) + toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。') + } + } + + resetFlowRun() + if (rawText && !reviewAction) { + startFlowStep('intent', '正在识别业务意图...') + if (waitForExpenseIntentConfirmation) { + startExpenseIntentConfirmationFlowPreview(rawText) + } else if (waitForExpenseSceneSelection) { + startExpenseSceneSelectionFlowPreview(rawText) + } else { + startSemanticFlowPreview(rawText, { attachmentCount: files.length }) + } + } + + const filePreviews = buildComposerFilePreviews(files) + rememberFilePreviews(filePreviews) + + // 只有在非静默模式下才添加用户消息 + if (!options.skipUserMessage) { + messages.value.push(createMessage('user', userText, fileNames)) + } + + if (waitForExpenseIntentConfirmation) { + messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], { + meta: ['等待确认意图'], + suggestedActions: buildExpenseIntentConfirmationActions(rawText) + })) + composerDraft.value = '' + composerBusinessTimeTags.value = [] + composerBusinessTimeDraftTouched.value = false + clearAttachedFiles() + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + return null + } + + if (waitForExpenseSceneSelection) { + messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], { + meta: ['等待选择场景'], + suggestedActions: buildExpenseSceneSelectionActions(rawText) + })) + composerDraft.value = '' + composerBusinessTimeTags.value = [] + composerBusinessTimeDraftTouched.value = false + clearAttachedFiles() + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + return null + } + + const pendingMessage = createMessage( + 'assistant', + options.pendingText || ( + isKnowledgeSession.value + ? '正在整理财务知识答案...' + : '正在识别并整理右侧核对信息...' + ), + [], + { + meta: ['处理中'] + } + ) + messages.value.push(pendingMessage) + + composerDraft.value = '' + composerBusinessTimeTags.value = [] + composerBusinessTimeDraftTouched.value = false + clearAttachedFiles() + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + nextTick(adjustComposerTextareaHeight) + + submitting.value = true + nextTick(scrollToBottom) + + let responsePayload = null + + try { + const user = currentUser.value || {} + let ocrPayload = null + let ocrSummary = '' + let ocrDocuments = [] + let ocrFilePreviews = [] + + if (files.length) { + const ocrStartedAt = Date.now() + startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt }) + try { + ocrPayload = await recognizeOcrFiles(files) + ocrSummary = buildOcrSummary(ocrPayload) + ocrDocuments = normalizeOcrDocuments(ocrPayload) + ocrFilePreviews = buildOcrFilePreviews(ocrPayload) + rememberFilePreviews(ocrFilePreviews) + completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt) + } catch (error) { + console.warn('OCR request failed:', error) + completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt) + } + } + + let effectiveFileNames = [...fileNames] + let effectiveOcrDocuments = [...ocrDocuments] + let effectiveOcrSummary = ocrSummary + + if (resolvedUploadDisposition === 'continue_existing') { + extraContext.review_action = 'link_to_existing_draft' + const inheritedReviewContext = buildReviewFormContextFromPayload( + activeReviewPayload.value, + reviewInlineForm.value + ) + if (inheritedReviewContext.review_form_values) { + extraContext.review_form_values = { + ...inheritedReviewContext.review_form_values, + ...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object' + ? extraContext.review_form_values + : {}) + } + } + if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) { + extraContext.business_time_context = inheritedReviewContext.business_time_context + } + effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames) + effectiveOcrDocuments = mergeUploadOcrDocuments( + buildOcrDocumentsFromReviewPayload(activeReviewPayload.value), + ocrDocuments + ) + effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments) + } else if (resolvedUploadDisposition === 'new_document') { + extraContext.review_action = 'create_new_claim_from_documents' + } + + startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), { + attachmentCount: effectiveFileNames.length, + waitForSceneSelection: waitForExpenseSceneSelection + }) + + const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) + const payload = await runOrchestrator( + { + source: 'user_message', + user_id: user.username || user.name || 'anonymous', + conversation_id: conversationId.value || null, + message: backendMessage, + context_json: { + role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [], + is_admin: Boolean(user.isAdmin), + name: user.name || '', + role: user.role || '', + department: user.department || user.departmentName || '', + department_name: user.department || user.departmentName || '', + position: user.position || '', + grade: user.grade || '', + employee_no: user.employeeNo || user.employee_no || '', + manager_name: user.managerName || user.manager_name || '', + employee_location: user.location || '', + cost_center: user.costCenter || user.cost_center || '', + finance_owner_name: user.financeOwnerName || user.finance_owner_name || '', + employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {}, + ...buildClientTimeContext(), + session_type: activeSessionType.value, + entry_source: props.entrySource, + user_input_text: systemGenerated ? '' : rawText, + attachment_names: effectiveFileNames, + attachment_count: effectiveFileNames.length, + draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, + ocr_summary: effectiveOcrSummary, + ocr_documents: effectiveOcrDocuments, + ...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), + ...extraContext + } + }, + isKnowledgeSession.value + ? { + timeoutMs: 18000, + timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。' + } + : {} + ) + responsePayload = payload + flowRunId.value = String(payload?.run_id || '').trim() + let flowRunDetail = null + if (flowRunId.value) { + flowRunDetail = await refreshFlowRunDetail() + } + + conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value + draftClaimId.value = + isKnowledgeSession.value + ? '' + : String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value + + replaceMessage( + pendingMessage.id, + createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { + meta: buildMessageMeta(payload, effectiveFileNames), + citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [], + suggestedActions: Array.isArray(payload?.result?.suggested_actions) + ? payload.result.suggested_actions + : [], + queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload), + draftPayload: payload?.result?.draft_payload || null, + reviewPayload: payload?.result?.review_payload || null, + riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] + }) + ) + currentInsight.value = buildAgentInsight( + payload, + effectiveFileNames, + mergeFilePreviews(filePreviews, ocrFilePreviews) + ) + completeFlowResult(payload, flowRunDetail) + + const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() + if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) { + try { + await syncComposerFilesToDraft(resolvedDraftClaimId, files) + } catch (error) { + console.warn('Failed to persist composer attachments to draft claim:', error) + toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。') + } + } + } catch (error) { + clearFlowSimulationTimers() + failCurrentFlowStep(error) + replaceMessage( + pendingMessage.id, + createMessage( + 'assistant', + error?.message || '无法连接后端 Orchestrator,请稍后重试。', + [], + { + meta: ['调用失败'] + } + ) + ) + currentInsight.value = buildErrorInsight(error, fileNames) + } finally { + submitting.value = false + composerUploadIntent.value = '' + nextTick(scrollToBottom) + } + + return responsePayload + } + + + return { + submitComposerInternal: submitComposer + } +} diff --git a/web/tests/api-request.test.mjs b/web/tests/api-request.test.mjs index b9a2250..e2bbc9a 100644 --- a/web/tests/api-request.test.mjs +++ b/web/tests/api-request.test.mjs @@ -135,6 +135,7 @@ async function testRejectsWithCustomTimeoutMessage() { }), (error) => { assert.equal(error.message, '知识问答整理超时,已停止等待。') + assert.equal(error.code, 'REQUEST_TIMEOUT') return true } ) diff --git a/web/tests/backend-health-timeout.test.mjs b/web/tests/backend-health-timeout.test.mjs new file mode 100644 index 0000000..975c54c --- /dev/null +++ b/web/tests/backend-health-timeout.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { checkBackendHealth, useBackendHealth } from '../src/composables/useBackendHealth.js' + +const routerScript = readFileSync( + fileURLToPath(new URL('../src/router/index.js', import.meta.url)), + 'utf8' +) + +test('app route guard allows stale healthy state when health check times out', () => { + assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/) +}) + +test('backend health timeout does not block app rendering when stale fallback is allowed', async () => { + const originalFetch = global.fetch + + global.fetch = async (_url, options = {}) => + new Promise((_, reject) => { + options.signal.addEventListener('abort', () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }) + }) + + try { + const ok = await checkBackendHealth({ + force: true, + allowStaleOnTimeout: true, + timeoutMs: 1 + }) + const { backendHealthy, backendError } = useBackendHealth() + + assert.equal(ok, true) + assert.equal(backendHealthy.value, true) + assert.match(backendError.value, /健康检查超时|health/i) + } finally { + global.fetch = originalFetch + } +}) diff --git a/web/tests/reimbursementTextInference.test.mjs b/web/tests/reimbursementTextInference.test.mjs index f602022..ed2931c 100644 --- a/web/tests/reimbursementTextInference.test.mjs +++ b/web/tests/reimbursementTextInference.test.mjs @@ -39,7 +39,7 @@ test('semantic intent detail includes recognized expense type', () => { } ] }), - '已识别为报销场景,当前目标是草稿生成,费用类型为交通费' + '已识别为报销场景,当前目标是信息核对,费用类型为交通费' ) }) diff --git a/web/tests/travel-reimbursement-review-drawer-switch.test.mjs b/web/tests/travel-reimbursement-review-drawer-switch.test.mjs index 44f80d0..6012459 100644 --- a/web/tests/travel-reimbursement-review-drawer-switch.test.mjs +++ b/web/tests/travel-reimbursement-review-drawer-switch.test.mjs @@ -15,6 +15,18 @@ const reimbursementService = readFileSync( fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)), 'utf8' ) +const reviewActionsScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)), + 'utf8' +) +const submitComposerScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), + 'utf8' +) +const attachmentsScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)), + 'utf8' +) test('review drawer tools expose the default review tab before conditional document and risk tabs', () => { assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/) @@ -143,3 +155,36 @@ test('review drawer save action is disabled while receipt recognition is submitt /function saveInlineReviewChanges\(\) \{[\s\S]*\|\| submitting\.value[\s\S]*\|\| sessionSwitchBusy\.value[\s\S]*\) return/ ) }) + +test('draft creation waits for composer attachments to be persisted before leaving submit state', () => { + assert.match( + submitComposerScript, + /try \{\s*await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\} catch \(error\) \{/s + ) + assert.doesNotMatch( + submitComposerScript, + /syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\.catch/ + ) + assert.ok( + submitComposerScript.indexOf('await syncComposerFilesToDraft(resolvedDraftClaimId, files)') < + submitComposerScript.indexOf('submitting.value = false'), + 'attachment persistence should finish before submit state is cleared' + ) + assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/) + assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/) + assert.match( + attachmentsScript, + /const targetItem = nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch/ + ) +}) + +test('review summary renders markdown and save draft relies on backend response only', () => { + assert.match( + createViewTemplate, + /message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="renderMarkdown\(message\.text\)"/ + ) + assert.doesNotMatch( + reviewActionsScript, + /messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/ + ) +}) diff --git a/web/tests/travel-request-detail-risk-advice.test.mjs b/web/tests/travel-request-detail-risk-advice.test.mjs index 74b0232..8b717f3 100644 --- a/web/tests/travel-request-detail-risk-advice.test.mjs +++ b/web/tests/travel-request-detail-risk-advice.test.mjs @@ -6,8 +6,14 @@ import { fileURLToPath } from 'node:url' import { buildAiAdviceViewModel, buildAttachmentInsightViewModel, - buildAttachmentRiskCards + buildAttachmentRiskCards, + extractRiskTagsFromText, + resolveRiskTags, + resolveRiskTagTone } from '../src/views/scripts/travelRequestDetailInsights.js' +import { + buildDraftBlockingIssues +} from '../src/views/scripts/travelRequestDetailExpenseModel.js' const detailViewTemplate = readFileSync( fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)), @@ -101,6 +107,24 @@ test('AI advice card splits every attachment risk point with basis and suggestio assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费'))) }) +test('risk cards carry severity and domain tags for statistics', () => { + const hotelRisk = { + tone: 'high', + title: '住宿超标待说明', + risk: '住宿标准:北京酒店 800 元/晚超出报销标准。' + } + const trafficRisk = { + tone: 'medium', + title: '交通票据提醒', + risk: '火车票说明格式待调整。' + } + + assert.deepEqual(resolveRiskTags(hotelRisk), ['#high_risk', '#hotel']) + assert.deepEqual(resolveRiskTags(trafficRisk), ['#middle_risk', '#traffic']) + assert.equal(resolveRiskTagTone('#hotel'), 'hotel') + assert.deepEqual(extractRiskTagsFromText('超标说明:#high_risk #hotel 原因'), ['#high_risk', '#hotel']) +}) + test('AI advice splits claim attachment risk flags into specific points', () => { const riskCards = buildAttachmentRiskCards({ claimRiskFlags: [ @@ -196,13 +220,23 @@ test('AI advice template renders grouped section titles with completion before r test('AI advice risk section uses compact card styling hooks', () => { assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/) + assert.match(detailViewTemplate, /v-if="card\.tags\?\.length" class="risk-card-tag-list"/) assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/) assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/) assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/) assert.match(detailViewStyle, /\.risk-advice-card\.low/) + assert.match(detailViewStyle, /\.risk-note-tag\.high/) + assert.match(detailViewStyle, /\.risk-note-tag\.hotel/) assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/) }) +test('expense rows show a major-risk warning icon before time', () => { + assert.match(detailViewTemplate, /'has-major-risk': isMajorExpenseRisk\(item\)/) + assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/) + assert.match(detailViewStyle, /\.expense-risk-indicator \{/) + assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/) +}) + test('AI advice shows only the latest manual return while preserving return count context', () => { const riskCards = buildAttachmentRiskCards({ claimRiskFlags: [ @@ -238,6 +272,10 @@ test('AI advice shows only the latest manual return while preserving return coun test('expense attachment actions keep preview as the only recognition entry point', () => { assert.match(detailViewTemplate, /:aria-label="resolveAttachmentPreviewTitle\(item\)"/) assert.match(detailViewScript, /return fileName \? `预览附件:\$\{fileName\}` : '预览附件'/) + assert.match(detailViewScript, /\.filter\(\(item\) => canPreviewAttachment\(item\)\)/) + assert.match(detailViewScript, /function hasStoredAttachmentReference\(item\) \{[\s\S]*return String\(item\?\.invoiceId \|\| ''\)\.includes\('\/'\)/) + assert.match(detailViewScript, /if \(metadata\) \{[\s\S]*return metadata\.previewable !== false[\s\S]*return true/) + assert.match(detailViewScript, /原件尚未保存到单据中,请重新上传后预览/) assert.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/) assert.doesNotMatch(detailViewTemplate, /点击识别按钮/) assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/) @@ -358,6 +396,8 @@ test('travel detail AI advice adds low risk reminders for optional receipts', () test('expense detail save is blocked while attachment recognition is running', () => { assert.match(detailViewScript, /const uploadingExpenseId = ref\(''\)/) assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*Boolean\(uploadingExpenseId\.value\)/) + assert.match(detailViewScript, /const canSubmit = computed\(\(\) => isEditableRequest\.value && !actionBusy\.value\)/) + assert.match(detailViewScript, /if \(draftBlockingIssues\.value\.length\) \{[\s\S]*请先补全草稿信息,再提交审批。/) assert.match( detailViewTemplate, /@click="saveExpenseEdit\(item\)"[\s\S]*:disabled="actionBusy"/ @@ -368,6 +408,36 @@ test('expense detail save is blocked while attachment recognition is running', ( ) }) +test('draft submit validation uses expense detail date and amount when claim summary is stale', () => { + const issues = buildDraftBlockingIssues( + { + profileName: '张三', + typeLabel: '待补充', + typeCode: 'office', + reason: '待补充', + location: '待补充', + occurredDisplay: '待补充', + amountValue: 0 + }, + [ + { + id: 'item-1', + itemDate: '2026-05-21', + itemType: 'office', + itemReason: '采购办公用品', + itemLocation: '', + itemAmount: 88, + invoiceId: 'claim-1/item-1/office-note.png' + } + ] + ) + + assert.ok(!issues.some((issue) => issue.includes('发生时间未完善'))) + assert.ok(!issues.some((issue) => issue.includes('报销金额未完善'))) + assert.ok(!issues.some((issue) => issue.includes('报销类型未完善'))) + assert.ok(!issues.some((issue) => issue.includes('报销事由未完善'))) +}) + test('transport ticket descriptions use route format and invalid format becomes risk advice', () => { assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/) assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_ticket'\]\)/) diff --git a/web/tests/travel-request-detail-submit-confirm.test.mjs b/web/tests/travel-request-detail-submit-confirm.test.mjs index c456cdd..7702abb 100644 --- a/web/tests/travel-request-detail-submit-confirm.test.mjs +++ b/web/tests/travel-request-detail-submit-confirm.test.mjs @@ -11,6 +11,10 @@ const detailViewScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)), 'utf8' ) +const detailExpenseModelScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)), + 'utf8' +) function extractFunction(source, name) { const signatureIndex = source.indexOf(`function ${name}(`) @@ -53,8 +57,21 @@ test('detail submit opens a confirmation dialog before calling submit API', () = assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/) }) +test('detail submit requires override reasons for high-risk claims', () => { + assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/) + assert.match(detailViewTemplate, /重大风险/) + assert.match(detailViewTemplate, /goToPreviousSubmitRisk/) + assert.match(detailViewTemplate, /goToNextSubmitRisk/) + assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/) + assert.match(detailViewScript, /const submitRiskWarnings = computed/) + assert.match(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/) + assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/) + assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s) + assert.match(detailViewScript, /超标说明:\$\{tags\}/) +}) + test('detail header and fallback progress use reimbursement wording', () => { assert.match(detailViewScript, /label:\s*'单据申请日期'/) - assert.match(detailViewScript, /label:\s*'创建单据'/) + assert.match(detailExpenseModelScript, /label:\s*'创建单据'/) assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/) })
+ + {{ currentSubmitRiskWarning.label }} + {{ currentSubmitRiskWarning.title }} + + {{ currentSubmitRiskWarning.risk }} + + + {{ tag }} + + + +