feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
336
web/src/views/AgentTraceCenterView.vue
Normal file
336
web/src/views/AgentTraceCenterView.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<section class="agent-trace-center">
|
||||
<section class="trace-filters panel">
|
||||
<label class="trace-field trace-search">
|
||||
<span>关键字</span>
|
||||
<input
|
||||
v-model="filters.keyword"
|
||||
type="search"
|
||||
placeholder="Run ID、会话 ID、摘要、场景"
|
||||
@keydown.enter.prevent="refresh"
|
||||
/>
|
||||
</label>
|
||||
<label class="trace-field">
|
||||
<span>Agent</span>
|
||||
<select v-model="filters.agent">
|
||||
<option value="">全部 Agent</option>
|
||||
<option value="orchestrator">Orchestrator</option>
|
||||
<option value="user_agent">User Agent</option>
|
||||
<option value="hermes">Hermes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="trace-field">
|
||||
<span>状态</span>
|
||||
<select v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="succeeded">成功</option>
|
||||
<option value="blocked">阻断</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="trace-field">
|
||||
<span>来源</span>
|
||||
<select v-model="filters.source">
|
||||
<option value="">全部来源</option>
|
||||
<option value="user_message">用户消息</option>
|
||||
<option value="schedule">定时任务</option>
|
||||
<option value="system_event">系统事件</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="trace-layout">
|
||||
<section class="trace-list panel">
|
||||
<div class="trace-list-head">
|
||||
<div>
|
||||
<strong>运行记录</strong>
|
||||
<span>共 {{ traces.length }} 条</span>
|
||||
</div>
|
||||
<div class="trace-list-actions">
|
||||
<button v-if="hasActiveFilter" class="trace-mini-action" type="button" @click="resetFilters">
|
||||
清空筛选
|
||||
</button>
|
||||
<button class="trace-mini-action" type="button" :disabled="loading" @click="refresh">
|
||||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
|
||||
<span>{{ loading ? '刷新中' : '刷新' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !traces.length" class="trace-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong>正在加载 Trace</strong>
|
||||
</div>
|
||||
<div v-else-if="errorMessage" class="trace-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<strong>加载失败</strong>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
<div v-else-if="!traces.length" class="trace-state">
|
||||
<i class="mdi mdi-timeline-question-outline"></i>
|
||||
<strong>暂无 Trace 记录</strong>
|
||||
<p>当前筛选条件下没有可展示的 Agent 运行链路。</p>
|
||||
</div>
|
||||
<table v-else class="trace-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>开始时间</th>
|
||||
<th>场景</th>
|
||||
<th>状态</th>
|
||||
<th>摘要</th>
|
||||
<th>Run ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="trace in traces"
|
||||
:key="trace.runId"
|
||||
:class="{ active: trace.runId === selectedRunId }"
|
||||
tabindex="0"
|
||||
@click="openTrace(trace.runId)"
|
||||
@keydown.enter.prevent="openTrace(trace.runId)"
|
||||
>
|
||||
<td>{{ formatTraceDateTime(trace.startedAt) }}</td>
|
||||
<td>
|
||||
<strong>{{ trace.title }}</strong>
|
||||
<span>{{ trace.sourceLabel }} · {{ trace.agent }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="trace-status" :class="resolveTraceStatusTone(trace.status)">
|
||||
{{ trace.statusLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ trace.summary || '暂无摘要' }}</strong>
|
||||
<span>{{ trace.eventCount || trace.toolCallCount }} 个事件 · {{ trace.failedToolCallCount }} 个失败工具</span>
|
||||
</td>
|
||||
<td class="trace-run-id">{{ trace.runId }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="trace-detail panel">
|
||||
<div v-if="detailLoading" class="trace-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong>正在读取链路详情</strong>
|
||||
</div>
|
||||
<div v-else-if="detailError" class="trace-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<strong>详情加载失败</strong>
|
||||
<p>{{ detailError }}</p>
|
||||
</div>
|
||||
<div v-else-if="!detail" class="trace-state">
|
||||
<i class="mdi mdi-timeline-clock-outline"></i>
|
||||
<strong>选择一条运行记录</strong>
|
||||
<p>点击左侧 Run ID,可查看完整事件时间线、输入输出和会话上下文。</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<header class="trace-detail-head">
|
||||
<div>
|
||||
<span class="trace-kicker">{{ detail.agent || 'agent' }}</span>
|
||||
<h4>{{ detail.runId }}</h4>
|
||||
<p>{{ detail.summary || detail.errorMessage || '暂无运行摘要' }}</p>
|
||||
</div>
|
||||
<span class="trace-status" :class="resolveTraceStatusTone(detail.status)">
|
||||
{{ detail.status || 'unknown' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="detail.fallbackGenerated" class="trace-inline-alert">
|
||||
当前运行没有持久化 trace event,已从 AgentRun、语义解析和工具调用合成只读时间线。
|
||||
</div>
|
||||
|
||||
<div class="trace-metrics">
|
||||
<div>
|
||||
<span>会话</span>
|
||||
<strong>{{ detail.conversationId || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>工具调用</span>
|
||||
<strong>{{ detail.toolCalls.length }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>事件数</span>
|
||||
<strong>{{ detail.events.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trace-detail-grid">
|
||||
<section class="trace-event-list">
|
||||
<button
|
||||
v-for="event in detail.events"
|
||||
:key="event.id"
|
||||
class="trace-event"
|
||||
:class="{ active: selectedEvent?.id === event.id }"
|
||||
type="button"
|
||||
@click="selectedEventId = event.id"
|
||||
>
|
||||
<span class="event-index">{{ event.sequence }}</span>
|
||||
<span class="event-copy">
|
||||
<strong>{{ event.title || event.eventName }}</strong>
|
||||
<small>{{ event.stage }} · {{ formatTraceDuration(event.durationMs) }}</small>
|
||||
</span>
|
||||
<span class="trace-status mini" :class="resolveTraceStatusTone(event.status)">
|
||||
{{ event.statusLabel }}
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="trace-event-payload">
|
||||
<template v-if="selectedEvent">
|
||||
<div class="payload-head">
|
||||
<div>
|
||||
<strong>{{ selectedEvent.title }}</strong>
|
||||
<span>{{ formatTraceDateTime(selectedEvent.startedAt) }}</span>
|
||||
</div>
|
||||
<span>{{ selectedEvent.summary || selectedEvent.eventName }}</span>
|
||||
</div>
|
||||
<p v-if="selectedEvent.errorMessage" class="trace-error-text">
|
||||
{{ selectedEvent.errorMessage }}
|
||||
</p>
|
||||
<div class="payload-columns">
|
||||
<div>
|
||||
<h5>输入</h5>
|
||||
<pre>{{ formatTraceJson(selectedEvent.inputJson) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h5>输出</h5>
|
||||
<pre>{{ formatTraceJson(selectedEvent.outputJson) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAgentTraceDetail, fetchAgentTraces } from '../services/agentTraces.js'
|
||||
import {
|
||||
formatTraceDateTime,
|
||||
formatTraceDuration,
|
||||
formatTraceJson,
|
||||
normalizeTraceDetail,
|
||||
normalizeTraceListItem,
|
||||
resolveTraceStatusTone
|
||||
} from '../utils/agentTraceViewModel.js'
|
||||
|
||||
defineOptions({
|
||||
name: 'AgentTraceCenterView'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const traces = ref([])
|
||||
const detail = ref(null)
|
||||
const selectedRunId = ref('')
|
||||
const selectedEventId = ref('')
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detailError = ref('')
|
||||
const filters = ref({
|
||||
keyword: '',
|
||||
agent: '',
|
||||
status: '',
|
||||
source: ''
|
||||
})
|
||||
|
||||
const hasActiveFilter = computed(() =>
|
||||
Object.values(filters.value).some((value) => String(value || '').trim())
|
||||
)
|
||||
const selectedEvent = computed(() =>
|
||||
detail.value?.events.find((event) => event.id === selectedEventId.value) || detail.value?.events[0] || null
|
||||
)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const payload = await fetchAgentTraces({
|
||||
keyword: filters.value.keyword,
|
||||
agent: filters.value.agent,
|
||||
status: filters.value.status,
|
||||
source: filters.value.source,
|
||||
limit: 80
|
||||
})
|
||||
traces.value = Array.isArray(payload) ? payload.map(normalizeTraceListItem) : []
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || 'Agent Trace 加载失败,请稍后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openTrace(runId) {
|
||||
const normalizedRunId = String(runId || '').trim()
|
||||
if (!normalizedRunId) {
|
||||
return
|
||||
}
|
||||
selectedRunId.value = normalizedRunId
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
router.replace({
|
||||
name: 'app-settings',
|
||||
query: {
|
||||
...route.query,
|
||||
section: 'agentTraces',
|
||||
run_id: normalizedRunId
|
||||
}
|
||||
})
|
||||
try {
|
||||
detail.value = normalizeTraceDetail(await fetchAgentTraceDetail(normalizedRunId))
|
||||
selectedEventId.value = detail.value.events[0]?.id || ''
|
||||
} catch (error) {
|
||||
detail.value = null
|
||||
detailError.value = error?.message || 'Agent Trace 详情加载失败,请稍后重试。'
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = {
|
||||
keyword: '',
|
||||
agent: '',
|
||||
status: '',
|
||||
source: ''
|
||||
}
|
||||
void refresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [filters.value.agent, filters.value.status, filters.value.source],
|
||||
() => {
|
||||
void refresh()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.run_id,
|
||||
(runId) => {
|
||||
const normalizedRunId = String(runId || '').trim()
|
||||
if (normalizedRunId && normalizedRunId !== selectedRunId.value) {
|
||||
void openTrace(normalizedRunId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
const initialRunId = String(route.query.run_id || '').trim()
|
||||
if (initialRunId) {
|
||||
await openTrace(initialRunId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/agent-trace-center-view.css"></style>
|
||||
@@ -151,6 +151,8 @@
|
||||
v-else-if="activeView === 'budget'"
|
||||
:current-user="currentUser"
|
||||
@open-assistant="openSmartEntry"
|
||||
@detail-open-change="budgetDetailOpen = $event"
|
||||
@detail-topbar-change="detailTopBarPayload = $event"
|
||||
/>
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<AuditView
|
||||
@@ -220,6 +222,7 @@ const detailTopBarPayload = ref(null)
|
||||
const auditDetailOpen = ref(false)
|
||||
const digitalEmployeeDetailOpen = ref(false)
|
||||
const receiptFolderDetailOpen = ref(false)
|
||||
const budgetDetailOpen = ref(false)
|
||||
const loginEntryAnimating = ref(false)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileSidebarOpen = ref(false)
|
||||
@@ -308,12 +311,17 @@ const DETAIL_TOPBAR_FALLBACKS = {
|
||||
receiptFolder: {
|
||||
title: '票据详情',
|
||||
desc: '查看票据源文件、OCR 识别信息与关联状态。'
|
||||
},
|
||||
budget: {
|
||||
title: '预算详情',
|
||||
desc: '查看预算周期、费用占比、审核信息与预算明细。'
|
||||
}
|
||||
}
|
||||
const customDetailTopBarActive = computed(() => (
|
||||
(activeView.value === 'audit' && auditDetailOpen.value) ||
|
||||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value) ||
|
||||
(activeView.value === 'receiptFolder' && receiptFolderDetailOpen.value)
|
||||
(activeView.value === 'receiptFolder' && receiptFolderDetailOpen.value) ||
|
||||
(activeView.value === 'budget' && budgetDetailOpen.value)
|
||||
))
|
||||
const resolvedTopBarView = computed(() => (
|
||||
customDetailTopBarActive.value
|
||||
|
||||
@@ -363,6 +363,16 @@
|
||||
|
||||
<div v-if="selectedSkillIsRule" class="detail-action-group">
|
||||
<template v-if="selectedSkillUsesJsonRisk">
|
||||
<button
|
||||
v-if="riskRuleHasPublishableRevision"
|
||||
class="minor-action primary-action"
|
||||
type="button"
|
||||
:disabled="!canPublishRiskRule || detailBusy"
|
||||
@click="openPublishRiskRuleDialog"
|
||||
>
|
||||
<i class="mdi mdi-rocket-launch-outline"></i>
|
||||
<span>发布修订</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canToggleRiskRuleEnabled"
|
||||
class="minor-action enable-action"
|
||||
|
||||
@@ -1,230 +1,337 @@
|
||||
<template>
|
||||
<section class="budget-center-page">
|
||||
<TableLoadingState
|
||||
v-if="budgetLoading"
|
||||
title="预算数据同步中"
|
||||
message="正在加载预算额度、使用情况与预警明细"
|
||||
icon="mdi mdi-chart-donut"
|
||||
floating
|
||||
blocking
|
||||
/>
|
||||
<article v-if="!detailMode" class="budget-list panel">
|
||||
<nav class="status-tabs budget-scope-tabs" aria-label="预算中心视角">
|
||||
<button
|
||||
v-for="tab in budgetScopeTabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
:class="{ active: activeBudgetScope === tab.value }"
|
||||
@click="activeBudgetScope = tab.value"
|
||||
>
|
||||
<span>{{ tab.label }}</span>
|
||||
<small>{{ tab.count }}</small>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="budget-summary-grid" aria-label="预算概览">
|
||||
<article
|
||||
v-for="(metric, index) in budgetMetrics"
|
||||
:key="metric.label"
|
||||
class="budget-summary-card"
|
||||
:class="metric.tone"
|
||||
:style="{ '--delay': `${index * 55}ms` }"
|
||||
>
|
||||
<div class="budget-summary-head">
|
||||
<span class="summary-icon">
|
||||
<i :class="metric.icon"></i>
|
||||
</span>
|
||||
<span class="summary-label">{{ metric.label }}</span>
|
||||
</div>
|
||||
<strong class="summary-value">{{ metric.value }}</strong>
|
||||
<div class="summary-comparison-row">
|
||||
<span class="comparison-pill" :class="metric.yoy.tone">
|
||||
<b>同比</b>
|
||||
<em>{{ metric.yoy.value }}</em>
|
||||
<i :class="metric.yoy.icon"></i>
|
||||
</span>
|
||||
<span class="comparison-pill" :class="metric.mom.tone">
|
||||
<b>环比</b>
|
||||
<em>{{ metric.mom.value }}</em>
|
||||
<i :class="metric.mom.icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<div class="document-toolbar budget-toolbar">
|
||||
<div class="filter-set">
|
||||
<label class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="budgetKeyword" type="search" placeholder="搜索预算编号、部门、编制人" />
|
||||
</label>
|
||||
|
||||
<section class="budget-filter-bar">
|
||||
<div class="budget-filter-set">
|
||||
<label>
|
||||
<span>预算年度</span>
|
||||
<EnterpriseSelect v-model="filters.year" :options="yearOptions" />
|
||||
</label>
|
||||
<label>
|
||||
<span>预算季度</span>
|
||||
<EnterpriseSelect v-model="filters.quarter" :options="quarters" />
|
||||
</label>
|
||||
<label>
|
||||
<span>费用类型</span>
|
||||
<EnterpriseSelect v-model="filters.expenseType" :options="expenseTypes" />
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<EnterpriseSelect v-model="filters.status" :options="statuses" />
|
||||
</label>
|
||||
<label class="budget-select-filter">
|
||||
<span>年度</span>
|
||||
<EnterpriseSelect v-model="filters.year" :options="yearOptions" />
|
||||
</label>
|
||||
|
||||
<label class="budget-select-filter">
|
||||
<span>季度</span>
|
||||
<EnterpriseSelect v-model="filters.quarter" :options="quarterOptions" />
|
||||
</label>
|
||||
|
||||
<label class="budget-select-filter">
|
||||
<span>状态</span>
|
||||
<EnterpriseSelect v-model="filters.status" :options="statusOptions" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="document-actions">
|
||||
<ElButton v-if="canEditBudget" class="budget-primary-btn" type="primary" @click="openBudgetAssistant()">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
<span>编辑预算</span>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-action-set">
|
||||
<ElButton v-if="canEditBudget" class="budget-primary-btn" type="primary" @click="openBudgetAssistant">
|
||||
|
||||
<div class="table-wrap budget-table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="budgetLoading" class="table-state">
|
||||
<TableLoadingState
|
||||
title="预算数据同步中"
|
||||
message="正在汇总部门预算、待审核草案与归档版本"
|
||||
icon="mdi mdi-chart-donut"
|
||||
floating
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="budgetError" class="table-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<strong>预算中心加载失败</strong>
|
||||
<p>{{ budgetError }}</p>
|
||||
</div>
|
||||
|
||||
<TableEmptyState
|
||||
v-else-if="showEmpty"
|
||||
:eyebrow="emptyState.eyebrow"
|
||||
:title="emptyState.title"
|
||||
:description="emptyState.desc"
|
||||
:icon="emptyState.icon"
|
||||
:tone="emptyState.tone"
|
||||
:art-label="emptyState.artLabel"
|
||||
:tips="emptyState.tips"
|
||||
/>
|
||||
|
||||
<table v-else class="budget-list-table" :class="activeBudgetScope">
|
||||
<colgroup v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
|
||||
<col class="col-budget-no">
|
||||
<col class="col-department">
|
||||
<col class="col-period">
|
||||
<col class="col-money">
|
||||
<col class="col-money">
|
||||
<col class="col-money">
|
||||
<col class="col-money">
|
||||
<col class="col-rate">
|
||||
<col class="col-status">
|
||||
<col class="col-updated">
|
||||
</colgroup>
|
||||
<colgroup v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
|
||||
<col class="col-budget-no">
|
||||
<col class="col-department">
|
||||
<col class="col-person">
|
||||
<col class="col-submitted">
|
||||
<col class="col-period">
|
||||
<col class="col-money">
|
||||
<col class="col-change">
|
||||
<col class="col-score">
|
||||
<col class="col-status">
|
||||
<col class="col-status">
|
||||
</colgroup>
|
||||
<colgroup v-else>
|
||||
<col class="col-budget-no">
|
||||
<col class="col-department">
|
||||
<col class="col-period">
|
||||
<col class="col-version">
|
||||
<col class="col-status">
|
||||
<col class="col-money">
|
||||
<col class="col-person">
|
||||
<col class="col-submitted">
|
||||
<col class="col-status">
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
|
||||
<th>预算编号</th>
|
||||
<th>部门</th>
|
||||
<th>预算周期</th>
|
||||
<th>年度预算</th>
|
||||
<th>季度预算</th>
|
||||
<th>月度预算</th>
|
||||
<th>剩余可用</th>
|
||||
<th>使用率</th>
|
||||
<th>风险</th>
|
||||
<th>更新时间</th>
|
||||
</tr>
|
||||
<tr v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
|
||||
<th>草案编号</th>
|
||||
<th>提交部门</th>
|
||||
<th>编制人</th>
|
||||
<th>提交时间</th>
|
||||
<th>预算周期</th>
|
||||
<th>申请预算</th>
|
||||
<th>较上一版</th>
|
||||
<th>AI 分析</th>
|
||||
<th>风险</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<th>归档编号</th>
|
||||
<th>部门</th>
|
||||
<th>预算周期</th>
|
||||
<th>版本</th>
|
||||
<th>归档类型</th>
|
||||
<th>原预算额</th>
|
||||
<th>审核人</th>
|
||||
<th>归档时间</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="row in visibleBudgetRows" :key="row.id" @click="handleRowAction(row)">
|
||||
<template v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
|
||||
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
|
||||
<td>{{ row.departmentName }}</td>
|
||||
<td>{{ row.periodLabel }}</td>
|
||||
<td>{{ row.annualAmountLabel }}</td>
|
||||
<td>{{ row.quarterAmountLabel }}</td>
|
||||
<td>{{ row.monthAmountLabel }}</td>
|
||||
<td>{{ row.availableAmountLabel }}</td>
|
||||
<td>
|
||||
<div class="budget-rate">
|
||||
<div><em :class="row.riskTone" :style="{ width: `${Math.min(row.usageRate, 100)}%` }"></em></div>
|
||||
<span>{{ row.usageRateLabel }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
|
||||
<td>{{ row.updatedAt }}</td>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
|
||||
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
|
||||
<td>{{ row.departmentName }}</td>
|
||||
<td>{{ row.compiler }}</td>
|
||||
<td>{{ row.submittedAt }}</td>
|
||||
<td>{{ row.periodLabel }}</td>
|
||||
<td>{{ row.requestedAmountLabel }}</td>
|
||||
<td><span class="budget-change">{{ row.changeRateLabel }}</span></td>
|
||||
<td><span class="budget-score">{{ row.aiScore }}分</span></td>
|
||||
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
|
||||
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
|
||||
<td>{{ row.departmentName }}</td>
|
||||
<td>{{ row.periodLabel }}</td>
|
||||
<td>{{ row.version }}</td>
|
||||
<td>{{ row.archiveType }}</td>
|
||||
<td>{{ row.quarterAmountLabel }}</td>
|
||||
<td>{{ row.reviewer }}</td>
|
||||
<td>{{ row.archivedAt }}</td>
|
||||
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="showTable" class="list-foot">
|
||||
<span class="page-summary">{{ pageSummary }}</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button class="page-nav" type="button" :disabled="budgetPage === 1" aria-label="上一页" @click="goToBudgetPage(budgetPage - 1)">
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in budgetPageNumbers"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: budgetPage === page }"
|
||||
type="button"
|
||||
:aria-current="budgetPage === page ? 'page' : undefined"
|
||||
@click="goToBudgetPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-nav" type="button" :disabled="budgetPage === totalBudgetPages" aria-label="下一页" @click="goToBudgetPage(budgetPage + 1)">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<EnterpriseSelect
|
||||
v-model="budgetPageSize"
|
||||
class="page-size-select"
|
||||
:options="budgetPageSizeOptions"
|
||||
size="small"
|
||||
@change="changeBudgetPageSize"
|
||||
/>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<EnterpriseDetailPage
|
||||
v-else-if="selectedBudget"
|
||||
variant="budget-detail-page"
|
||||
back-label="返回预算中心"
|
||||
@back="backToList"
|
||||
>
|
||||
<section class="budget-period-grid" aria-label="预算周期金额">
|
||||
<article v-for="item in selectedBudget.periodRows" :key="item.label" class="budget-period-card">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<p>{{ item.desc }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<EnterpriseDetailCard
|
||||
class="budget-detail-card budget-chart-card"
|
||||
title="费用预算使用占比"
|
||||
description="按费用类型展示已发生、已占用和剩余额度"
|
||||
>
|
||||
<BudgetTrendChart
|
||||
:labels="selectedBudgetUsageData.labels"
|
||||
:budget="selectedBudgetUsageData.budget"
|
||||
:used="selectedBudgetUsageData.used"
|
||||
:occupied="selectedBudgetUsageData.occupied"
|
||||
:available="selectedBudgetUsageData.available"
|
||||
/>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard
|
||||
class="budget-detail-card budget-status-explain-card"
|
||||
title="预算状态说明"
|
||||
description="说明当前预算发布状态与风险判断依据"
|
||||
>
|
||||
<div class="budget-status-explain-list">
|
||||
<article
|
||||
v-for="item in selectedBudgetStatusNotes"
|
||||
:key="item.label"
|
||||
class="budget-status-explain-item"
|
||||
>
|
||||
<span :class="['budget-status-tag', item.tone]">{{ item.value }}</span>
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<p>{{ item.desc }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard
|
||||
class="budget-detail-card budget-category-card"
|
||||
title="费用类型预算"
|
||||
description="仅覆盖当前 demo 阶段预算管理费种"
|
||||
>
|
||||
<div class="budget-detail-table-wrap">
|
||||
<table class="budget-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>费用类型</th>
|
||||
<th>预算金额</th>
|
||||
<th>已发生</th>
|
||||
<th>已占用</th>
|
||||
<th>剩余</th>
|
||||
<th>使用率</th>
|
||||
<th>提醒</th>
|
||||
<th>告警</th>
|
||||
<th>风险</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in selectedBudget.categoryRows" :key="item.code">
|
||||
<td><strong>{{ item.name }}</strong></td>
|
||||
<td>{{ item.amountLabel }}</td>
|
||||
<td>{{ item.usedLabel }}</td>
|
||||
<td>{{ item.occupiedLabel }}</td>
|
||||
<td>{{ item.availableLabel }}</td>
|
||||
<td>{{ item.usageRateLabel }}</td>
|
||||
<td><span class="budget-threshold reminder">{{ item.reminderLine }}</span></td>
|
||||
<td><span class="budget-threshold alert">{{ item.alertLine }}</span></td>
|
||||
<td><span class="budget-threshold risk">{{ item.riskLine }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<template #actions>
|
||||
<ElButton
|
||||
v-if="selectedBudget.scope === BUDGET_SCOPE_REVIEW && canAuditBudgetDrafts"
|
||||
class="budget-primary-btn"
|
||||
type="primary"
|
||||
@click="openBudgetReviewAssistant(selectedBudget)"
|
||||
>
|
||||
<i class="mdi mdi-clipboard-check-outline"></i>
|
||||
<span>进入审核</span>
|
||||
</ElButton>
|
||||
<ElButton v-if="canEditBudget" class="budget-ghost-btn" @click="openBudgetAssistant()">
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
<span>编辑预算</span>
|
||||
</ElButton>
|
||||
<ElButton class="budget-ghost-btn">
|
||||
<i class="mdi mdi-text-box-outline"></i>
|
||||
<span>预算详情</span>
|
||||
</ElButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="budget-work-grid" :class="{ 'single-department': !canSwitchDepartments }">
|
||||
<aside v-if="canSwitchDepartments" class="budget-department-panel">
|
||||
<header>
|
||||
<strong>部门切换</strong>
|
||||
</header>
|
||||
<ElInput
|
||||
v-model="departmentKeyword"
|
||||
class="department-search-input"
|
||||
clearable
|
||||
placeholder="搜索部门"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
</template>
|
||||
</ElInput>
|
||||
<nav class="department-list" aria-label="预算部门">
|
||||
<ElButton
|
||||
v-for="department in visibleDepartments"
|
||||
:key="department.code"
|
||||
class="department-switch-btn"
|
||||
text
|
||||
:class="{ active: department.code === activeDepartmentCode }"
|
||||
@click="activeDepartmentCode = department.code"
|
||||
>
|
||||
<i :class="department.icon"></i>
|
||||
<span>{{ department.name }}</span>
|
||||
</ElButton>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<article class="budget-table-panel">
|
||||
<header>
|
||||
<strong>当前部门:{{ activeDepartmentName }}</strong>
|
||||
<ElInput
|
||||
v-model="budgetTableKeyword"
|
||||
class="budget-table-search"
|
||||
clearable
|
||||
placeholder="筛选预算明细"
|
||||
aria-label="筛选预算明细"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
</template>
|
||||
</ElInput>
|
||||
</header>
|
||||
<div class="budget-table-wrap">
|
||||
<ElTable
|
||||
:data="visibleBudgetRows"
|
||||
class="budget-data-table"
|
||||
border
|
||||
stripe
|
||||
>
|
||||
<ElTableColumn prop="compiledAt" label="编制时间" min-width="150" align="center" />
|
||||
<ElTableColumn prop="compiler" label="编制人" min-width="120" align="center" />
|
||||
<ElTableColumn prop="reviewer" label="审核人" min-width="120" align="center" />
|
||||
<ElTableColumn prop="expenseType" label="费用类型" min-width="140" align="center" />
|
||||
<ElTableColumn prop="total" label="预算金额(元)" min-width="140" align="right" />
|
||||
<ElTableColumn prop="used" label="已发生(元)" min-width="130" align="right" />
|
||||
<ElTableColumn prop="occupied" label="已占用(元)" min-width="130" align="right" />
|
||||
<ElTableColumn prop="left" label="剩余可用(元)" min-width="140" align="right" />
|
||||
<ElTableColumn label="使用率" min-width="128" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="budget-rate">
|
||||
<div><em :class="row.rateTone" :style="{ width: `${Math.min(row.rate, 100)}%` }"></em></div>
|
||||
<span>{{ row.rate }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="提醒阈值" min-width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="budget-threshold-badge reminder">{{ row.reminderLine }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="告警阈值" min-width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="budget-threshold-badge alert">{{ row.alertLine }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="风险阈值" min-width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="budget-threshold-badge risk">{{ row.riskLine }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
<footer class="budget-table-foot">
|
||||
<ElPagination
|
||||
class="budget-pager"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:pager-count="5"
|
||||
:current-page="budgetPage"
|
||||
:page-size="budgetPageSize"
|
||||
:total="totalBudgetRows"
|
||||
@current-change="goToBudgetPage"
|
||||
/>
|
||||
<EnterpriseSelect
|
||||
v-model="budgetPageSize"
|
||||
class="budget-page-size-select"
|
||||
:options="budgetPageSizeOptions"
|
||||
aria-label="每页条数"
|
||||
size="small"
|
||||
/>
|
||||
<span class="budget-page-summary">
|
||||
共 {{ totalBudgetRows }} 条,当前第 {{ budgetPage }} / {{ totalBudgetPages }} 页
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="budget-bottom-grid">
|
||||
<article class="budget-chart-panel">
|
||||
<header class="budget-card-head">
|
||||
<strong>费用预算使用占比</strong>
|
||||
<div class="budget-chart-legend">
|
||||
<span><i class="legend-line used"></i>已使用</span>
|
||||
<span><i class="legend-line occupied"></i>已占用</span>
|
||||
<span><i class="legend-line available"></i>剩余可用</span>
|
||||
</div>
|
||||
</header>
|
||||
<BudgetTrendChart
|
||||
:labels="budgetUsageData.labels"
|
||||
:budget="budgetUsageData.budget"
|
||||
:used="budgetUsageData.used"
|
||||
:occupied="budgetUsageData.occupied"
|
||||
:available="budgetUsageData.available"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="budget-alert-panel">
|
||||
<header class="budget-card-head">
|
||||
<strong>预算预警</strong>
|
||||
<ElButton v-if="warnings.length" class="budget-link-btn" text>查看全部</ElButton>
|
||||
</header>
|
||||
<div v-if="warnings.length" class="budget-alert-list">
|
||||
<div v-for="alert in warnings" :key="alert.id" class="budget-alert-row">
|
||||
<i :class="alert.tone"></i>
|
||||
<strong>{{ alert.title }}</strong>
|
||||
<span>{{ alert.desc }}</span>
|
||||
<time v-if="alert.date">{{ alert.date }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="budget-alert-empty">
|
||||
<span class="budget-alert-empty-icon">
|
||||
<i class="mdi mdi-shield-check-outline"></i>
|
||||
</span>
|
||||
<strong>暂无预算预警</strong>
|
||||
<p>当前范围内预算使用率未达到预警线。</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</template>
|
||||
</EnterpriseDetailPage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/BudgetCenterView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
|
||||
<style scoped src="../assets/styles/views/budget-center-view.css"></style>
|
||||
|
||||
@@ -85,6 +85,9 @@
|
||||
:selected-status="selectedStatus"
|
||||
:selected-status-label="selectedStatusLabel"
|
||||
:status-options="statusOptions"
|
||||
:selected-skill-category="selectedSkillCategory"
|
||||
:selected-skill-category-label="selectedSkillCategoryLabel"
|
||||
:skill-category-options="skillCategoryOptions"
|
||||
:selected-enabled-state="selectedEnabledState"
|
||||
:selected-enabled-label="selectedEnabledLabel"
|
||||
:enabled-state-options="enabledStateOptions"
|
||||
@@ -212,6 +215,7 @@ watch(
|
||||
)
|
||||
const keyword = ref('')
|
||||
const selectedStatus = ref('')
|
||||
const selectedSkillCategory = ref('')
|
||||
const selectedEnabledState = ref('')
|
||||
const selectedExecutionMode = ref('')
|
||||
const activeFilterPopover = ref('')
|
||||
@@ -235,10 +239,17 @@ const scheduleEditorBusy = computed(() => actionState.value === 'save-digital-sc
|
||||
const statusOptions = STATUS_OPTIONS
|
||||
const enabledStateOptions = ENABLED_STATE_OPTIONS
|
||||
const executionModeOptions = DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS
|
||||
const skillCategoryOptions = [
|
||||
{ value: '', label: '全部技能类型' },
|
||||
...DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS.map((value) => ({ value, label: value }))
|
||||
]
|
||||
|
||||
const selectedStatusLabel = computed(() =>
|
||||
statusOptions.find((item) => item.value === selectedStatus.value)?.label || '全部状态'
|
||||
)
|
||||
const selectedSkillCategoryLabel = computed(() =>
|
||||
skillCategoryOptions.find((item) => item.value === selectedSkillCategory.value)?.label || '全部技能类型'
|
||||
)
|
||||
const selectedEnabledLabel = computed(() =>
|
||||
enabledStateOptions.find((item) => item.value === selectedEnabledState.value)?.label || '全部启动状态'
|
||||
)
|
||||
@@ -250,6 +261,9 @@ const activeFilterTokens = computed(() => {
|
||||
if (selectedStatus.value) {
|
||||
tokens.push(`资产状态:${selectedStatusLabel.value}`)
|
||||
}
|
||||
if (selectedSkillCategory.value) {
|
||||
tokens.push(`技能类型:${selectedSkillCategoryLabel.value}`)
|
||||
}
|
||||
if (selectedEnabledState.value) {
|
||||
tokens.push(`启动状态:${selectedEnabledLabel.value}`)
|
||||
}
|
||||
@@ -272,6 +286,7 @@ const visibleEmployees = computed(() => {
|
||||
keyword: keyword.value,
|
||||
selectedEnabledState: selectedEnabledState.value,
|
||||
selectedExecutionMode: selectedExecutionMode.value,
|
||||
selectedSkillCategory: selectedSkillCategory.value,
|
||||
selectedStatus: selectedStatus.value
|
||||
})
|
||||
})
|
||||
@@ -288,6 +303,9 @@ function selectFilter(type, value) {
|
||||
if (type === 'status') {
|
||||
selectedStatus.value = value
|
||||
}
|
||||
if (type === 'skillCategory') {
|
||||
selectedSkillCategory.value = value
|
||||
}
|
||||
if (type === 'enabled') {
|
||||
selectedEnabledState.value = value
|
||||
}
|
||||
@@ -300,6 +318,7 @@ function selectFilter(type, value) {
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
selectedStatus.value = ''
|
||||
selectedSkillCategory.value = ''
|
||||
selectedEnabledState.value = ''
|
||||
selectedExecutionMode.value = ''
|
||||
closeFilterPopover()
|
||||
|
||||
@@ -29,10 +29,16 @@
|
||||
<p>{{ hermesRun.result_summary || '暂无运行摘要。' }}</p>
|
||||
<p v-if="hermesRun.status === 'running'" class="hero-hint">运行中每 5 秒自动刷新一次详情。</p>
|
||||
</div>
|
||||
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>刷新详情</span>
|
||||
</button>
|
||||
<div class="hero-actions">
|
||||
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>刷新详情</span>
|
||||
</button>
|
||||
<button class="refresh-btn" type="button" @click="openAgentTraceCenter">
|
||||
<i class="mdi mdi-timeline-text-outline"></i>
|
||||
<span>查看 Trace</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
@@ -438,6 +444,14 @@ async function loadDetail(options = {}) {
|
||||
function backToLogs() {
|
||||
router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
|
||||
}
|
||||
|
||||
function openAgentTraceCenter() {
|
||||
const runId = String(hermesRun.value?.run_id || '').trim()
|
||||
if (!runId) {
|
||||
return
|
||||
}
|
||||
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.logKind, route.params.logId],
|
||||
|
||||
@@ -137,6 +137,16 @@
|
||||
@update:window-days="setRiskWindowDays"
|
||||
/>
|
||||
|
||||
<DigitalEmployeeDashboard
|
||||
v-else-if="activeDashboard === 'digitalEmployee'"
|
||||
:dashboard="digitalEmployeeDashboard"
|
||||
:loading="digitalEmployeeDashboardLoading"
|
||||
:error="digitalEmployeeDashboardError"
|
||||
:daily-rows="digitalEmployeeDailyRows"
|
||||
:task-ranking="digitalEmployeeTaskRanking"
|
||||
:category-rows="digitalEmployeeCategoryRows"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="system-observability-grid">
|
||||
<article class="panel dashboard-card system-agent-ratio-panel">
|
||||
@@ -295,6 +305,7 @@ import SystemAgentRatioBar from '../components/charts/SystemAgentRatioBar.vue'
|
||||
import SystemLoginWaveChart from '../components/charts/SystemLoginWaveChart.vue'
|
||||
import SystemTokenDailyWaveChart from '../components/charts/SystemTokenDailyWaveChart.vue'
|
||||
import SystemUserTokenPie from '../components/charts/SystemUserTokenPie.vue'
|
||||
import DigitalEmployeeDashboard from '../components/dashboard/DigitalEmployeeDashboard.vue'
|
||||
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
|
||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||
|
||||
@@ -318,6 +329,13 @@ const {
|
||||
bottlenecks,
|
||||
budgetSummary,
|
||||
departmentRangeOptions,
|
||||
digitalEmployeeCategoryRows,
|
||||
digitalEmployeeDashboard,
|
||||
digitalEmployeeDashboardError,
|
||||
digitalEmployeeDashboardLoading,
|
||||
digitalEmployeeDailyRows,
|
||||
digitalEmployeeKpiMetrics,
|
||||
digitalEmployeeTaskRanking,
|
||||
kpiMetrics,
|
||||
rankedDepartments,
|
||||
riskDashboard,
|
||||
@@ -350,15 +368,15 @@ const {
|
||||
const activeDashboard = computed(() => {
|
||||
if (props.dashboard === 'system') return 'system'
|
||||
if (props.dashboard === 'risk') return 'risk'
|
||||
if (props.dashboard === 'digitalEmployee') return 'digitalEmployee'
|
||||
return 'finance'
|
||||
})
|
||||
const activeKpiMetrics = computed(() => (
|
||||
activeDashboard.value === 'system'
|
||||
? systemKpiMetrics.value
|
||||
: activeDashboard.value === 'risk'
|
||||
? riskKpiMetrics.value
|
||||
: kpiMetrics.value
|
||||
))
|
||||
const activeKpiMetrics = computed(() => {
|
||||
if (activeDashboard.value === 'system') return systemKpiMetrics.value
|
||||
if (activeDashboard.value === 'digitalEmployee') return digitalEmployeeKpiMetrics.value
|
||||
if (activeDashboard.value === 'risk') return riskKpiMetrics.value
|
||||
return kpiMetrics.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/overview-view.css"></style>
|
||||
|
||||
@@ -146,28 +146,113 @@
|
||||
loading-icon="mdi mdi-receipt-text-outline"
|
||||
@back="backToList"
|
||||
>
|
||||
<template #main>
|
||||
<EnterpriseDetailCard class="receipt-basic-panel" title="票据关键字段">
|
||||
<template #actions>
|
||||
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<section class="receipt-detail-toolbar panel">
|
||||
<div class="receipt-detail-title">
|
||||
<strong>票据详情</strong>
|
||||
<span>{{ receiptDetailTitle }}</span>
|
||||
<p>查看识别结果、校验状态、关联单据与处理记录</p>
|
||||
</div>
|
||||
|
||||
<div class="receipt-key-grid">
|
||||
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
|
||||
<span>{{ field.label }}</span>
|
||||
<input
|
||||
:value="field.value"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
@input="updateReceiptField(field, $event.target.value)"
|
||||
<div class="receipt-toolbar-actions">
|
||||
<button class="minor-action" type="button" @click="reloadCurrentReceipt">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>重新读取</span>
|
||||
</button>
|
||||
<button
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="selectedReceipt?.status === 'linked'"
|
||||
@click="openAssociateDialogForCurrentReceipt"
|
||||
>
|
||||
<i class="mdi mdi-link-variant-plus"></i>
|
||||
<span>关联单据</span>
|
||||
</button>
|
||||
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="receipt-dashboard">
|
||||
<EnterpriseDetailCard class="receipt-preview-panel receipt-dashboard-preview" title="票据预览">
|
||||
<div class="receipt-preview-frame">
|
||||
<div class="receipt-preview-box">
|
||||
<img
|
||||
v-if="previewKind === 'image' && previewObjectUrl"
|
||||
:src="previewObjectUrl"
|
||||
:style="{ transform: previewTransform }"
|
||||
alt="票据预览"
|
||||
/>
|
||||
</label>
|
||||
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
|
||||
<div v-else class="preview-empty">
|
||||
<i class="mdi mdi-file-eye-outline"></i>
|
||||
<strong>当前文件暂不支持内嵌预览</strong>
|
||||
<p>请确认源文件是否支持预览,或重新上传清晰图片/PDF。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-other-info">
|
||||
<footer class="receipt-preview-tools" aria-label="票据预览工具">
|
||||
<span class="preview-page">{{ previewPageLabel }}</span>
|
||||
<div class="preview-tool-group">
|
||||
<button type="button" :disabled="previewZoom <= 0.6" aria-label="缩小预览" @click="adjustPreviewZoom(-0.1)">
|
||||
<i class="mdi mdi-minus"></i>
|
||||
</button>
|
||||
<strong>{{ Math.round(previewZoom * 100) }}%</strong>
|
||||
<button type="button" :disabled="previewZoom >= 1.8" aria-label="放大预览" @click="adjustPreviewZoom(0.1)">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="preview-tool-group">
|
||||
<button type="button" aria-label="重置预览" @click="resetPreviewView">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
</button>
|
||||
<button type="button" aria-label="旋转预览" @click="rotatePreview">
|
||||
<i class="mdi mdi-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<div class="receipt-dashboard-side">
|
||||
<EnterpriseDetailCard class="receipt-basic-panel" title="基础信息">
|
||||
<template #actions>
|
||||
<span class="receipt-card-count">{{ keyReceiptFields.length }} 项可编辑</span>
|
||||
</template>
|
||||
|
||||
<div class="receipt-key-grid">
|
||||
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
|
||||
<span>{{ field.label }}</span>
|
||||
<input
|
||||
:value="field.value"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
@input="updateReceiptField(field, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="receipt-static-grid">
|
||||
<div v-for="item in basicInfoItems" :key="item.label" class="receipt-static-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-ocr-panel" title="OCR识别结果">
|
||||
<div v-if="ocrPreviewFields.length" class="receipt-ocr-grid">
|
||||
<label v-for="field in ocrPreviewFields" :key="field.key || field.label" class="receipt-ocr-field">
|
||||
<span>{{ field.label || field.key }}</span>
|
||||
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="receipt-field-empty">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<span>暂无可展示的 OCR 识别字段</span>
|
||||
</div>
|
||||
|
||||
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse">
|
||||
<ElCollapseItem name="other">
|
||||
<template #title>
|
||||
@@ -193,30 +278,50 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="receipt-field-empty">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<span>暂无其他可编辑信息</span>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<template #side>
|
||||
<EnterpriseDetailCard class="receipt-preview-panel" title="原始文件">
|
||||
<div class="receipt-preview-box">
|
||||
<img v-if="previewKind === 'image' && previewObjectUrl" :src="previewObjectUrl" alt="票据预览" />
|
||||
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
|
||||
<div v-else class="preview-empty">
|
||||
<i class="mdi mdi-file-eye-outline"></i>
|
||||
<strong>当前文件暂不支持内嵌预览</strong>
|
||||
<p>请确认源文件是否支持预览,或重新上传清晰图片/PDF。</p>
|
||||
<EnterpriseDetailCard class="receipt-status-panel" title="处理状态">
|
||||
<div class="receipt-status-grid">
|
||||
<div v-for="item in receiptStatusItems" :key="item.label" class="receipt-status-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
</EnterpriseDetailCard>
|
||||
</div>
|
||||
|
||||
<div class="receipt-dashboard-bottom">
|
||||
<EnterpriseDetailCard class="receipt-info-panel" title="关联单据信息">
|
||||
<div class="receipt-data-list">
|
||||
<div v-for="item in linkedClaimItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-log-panel" title="处理记录 / 操作日志">
|
||||
<ol class="receipt-log-list">
|
||||
<li v-for="item in operationLogs" :key="`${item.time}-${item.label}`">
|
||||
<span>{{ item.time }}</span>
|
||||
<strong>{{ item.operator }}</strong>
|
||||
<p>{{ item.label }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</EnterpriseDetailCard>
|
||||
|
||||
<EnterpriseDetailCard class="receipt-info-panel" title="归档信息">
|
||||
<div class="receipt-data-list">
|
||||
<div v-for="item in archiveInfoItems" :key="item.label" class="receipt-data-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template #actions>
|
||||
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
|
||||
@@ -304,6 +409,7 @@ import {
|
||||
fetchReceiptFolderItems,
|
||||
updateReceiptFolderItem
|
||||
} from '../services/receiptFolder.js'
|
||||
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
|
||||
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
|
||||
|
||||
const NEW_CLAIM_VALUE = '__new_claim__'
|
||||
@@ -425,6 +531,26 @@ const {
|
||||
syncEditableFieldsToTopLevel,
|
||||
updateReceiptField
|
||||
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
|
||||
const {
|
||||
adjustPreviewZoom,
|
||||
archiveInfoItems,
|
||||
basicInfoItems,
|
||||
linkedClaimItems,
|
||||
ocrPreviewFields,
|
||||
operationLogs,
|
||||
previewPageLabel,
|
||||
previewTransform,
|
||||
previewZoom,
|
||||
receiptStatusItems,
|
||||
resetPreviewView,
|
||||
rotatePreview
|
||||
} = createReceiptDetailDashboardModel({
|
||||
detailForm,
|
||||
editableOtherFields,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
selectedReceipt
|
||||
})
|
||||
const receiptDetailTopBarPayload = computed(() => (
|
||||
detailMode.value
|
||||
? {
|
||||
@@ -516,6 +642,7 @@ function fillDetailForm(detail) {
|
||||
? detail.fields.map((field) => ({ ...field }))
|
||||
: []
|
||||
expandedFieldPanels.value = []
|
||||
resetPreviewView()
|
||||
ensureEditableReceiptFields()
|
||||
syncEditableFieldsToTopLevel()
|
||||
}
|
||||
@@ -542,6 +669,11 @@ function backToList() {
|
||||
revokePreviewUrl()
|
||||
}
|
||||
|
||||
async function reloadCurrentReceipt() {
|
||||
if (!selectedReceipt.value?.id || detailLoading.value) return
|
||||
await openDetail(selectedReceipt.value)
|
||||
}
|
||||
|
||||
async function saveDetail() {
|
||||
if (!selectedReceipt.value?.id || savingDetail.value) return
|
||||
savingDetail.value = true
|
||||
@@ -575,6 +707,15 @@ async function openAssociateDialog() {
|
||||
await loadDraftClaims()
|
||||
}
|
||||
|
||||
async function openAssociateDialogForCurrentReceipt() {
|
||||
if (!selectedReceipt.value?.id || selectedReceipt.value.status === 'linked') return
|
||||
selectedReceiptIds.value = [selectedReceipt.value.id]
|
||||
targetDraftId.value = NEW_CLAIM_VALUE
|
||||
associateStep.value = 2
|
||||
associateDialogOpen.value = true
|
||||
await loadDraftClaims()
|
||||
}
|
||||
|
||||
function closeAssociateDialog() {
|
||||
if (associateBusy.value) return
|
||||
associateDialogOpen.value = false
|
||||
|
||||
@@ -43,7 +43,10 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-content" :class="{ 'settings-content-fill': activeSection === 'systemLogs' }">
|
||||
<div
|
||||
class="settings-content"
|
||||
:class="{ 'settings-content-fill': ['systemLogs', 'agentTraces'].includes(activeSection) }"
|
||||
>
|
||||
<template v-if="activeSection === 'profile'">
|
||||
<section class="settings-card">
|
||||
<div class="card-head">
|
||||
@@ -439,9 +442,13 @@
|
||||
<LogsView v-else class="settings-logs-view" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'agentTraces'">
|
||||
<AgentTraceCenterView class="settings-trace-center-view" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'mail'">
|
||||
<MailSettingsPanel :mail-form="pageState.mailForm" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<article class="progress-card panel">
|
||||
<div class="progress-block">
|
||||
<div class="progress-head">
|
||||
<h3>{{ isApplicationDocument ? '申请进度' : isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
|
||||
<h3>{{ isApplicationDocument ? '申请进度' : '报销进度' }}</h3>
|
||||
</div>
|
||||
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
|
||||
<div
|
||||
@@ -435,22 +435,16 @@
|
||||
:key="card.id"
|
||||
:class="['risk-advice-card', card.tone]"
|
||||
>
|
||||
<div class="risk-advice-card-head">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
<div class="risk-advice-card-main">
|
||||
<div class="risk-advice-card-head">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
<span>规则依据</span>
|
||||
<ul>
|
||||
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span>修改建议</span>
|
||||
<p>{{ card.suggestion }}</p>
|
||||
</div>
|
||||
<div class="risk-advice-compact-meta">
|
||||
<span v-if="card.ruleBasis?.length">{{ card.ruleBasis[0] }}</span>
|
||||
<em>{{ card.suggestion }}</em>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -461,11 +455,12 @@
|
||||
v-if="request.claimId"
|
||||
:claim-id="request.claimId"
|
||||
/>
|
||||
<EmployeeProfileRiskCard
|
||||
v-if="showEmployeeRiskProfile"
|
||||
:profile="employeeRiskProfile"
|
||||
:loading="employeeRiskProfileLoading"
|
||||
:error="employeeRiskProfileError"
|
||||
<StageRiskAdviceCard
|
||||
v-if="showStageRiskAdvice"
|
||||
:request="request"
|
||||
:expense-items="expenseItems"
|
||||
:ai-advice="aiAdvice"
|
||||
:is-application-document="isApplicationDocument"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@@ -481,8 +476,8 @@
|
||||
{{ deleteBusy ? '删除中' : deleteActionLabel }}
|
||||
</button>
|
||||
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
|
||||
<i class="mdi mdi-send-circle-outline"></i>
|
||||
{{ submitBusy ? '提交中' : '提交审批' }}
|
||||
<i :class="submitActionIcon"></i>
|
||||
{{ submitActionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="canReturnRequest || canApproveRequest || canPayRequest || canDeleteRequest" class="approval-action-group" aria-label="单据管理操作">
|
||||
@@ -658,9 +653,9 @@
|
||||
badge="提交确认"
|
||||
badge-tone="warning"
|
||||
:title="`确认提交 ${request.id} 吗?`"
|
||||
:description="isApplicationDocument ? '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' : '请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。'"
|
||||
:description="submitConfirmDescription"
|
||||
cancel-text="返回核对"
|
||||
confirm-text="确认提交"
|
||||
:confirm-text="submitConfirmText"
|
||||
busy-text="提交中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-send-circle-outline"
|
||||
@@ -692,7 +687,7 @@
|
||||
badge="重大风险"
|
||||
badge-tone="danger"
|
||||
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
|
||||
description="如仍需提交审批,请逐条填写违规或超标原因,系统会写入附加说明并用于后续风险统计。"
|
||||
description="如仍需进入下一步,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
|
||||
cancel-text="返回整改"
|
||||
confirm-text="保存原因并继续"
|
||||
busy-text="保存中..."
|
||||
|
||||
@@ -134,20 +134,26 @@ export default {
|
||||
Boolean(selectedSkill.value?.id) &&
|
||||
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
|
||||
)
|
||||
const canOpenRiskRuleReviewSubmit = computed(
|
||||
() => false
|
||||
)
|
||||
const canOpenRiskRuleReviewSubmit = computed(() => false)
|
||||
const canSubmitRiskRuleReview = computed(
|
||||
() =>
|
||||
canOpenRiskRuleReviewSubmit.value &&
|
||||
riskRuleTestPassed.value
|
||||
)
|
||||
const canReturnRiskRule = computed(
|
||||
() => false
|
||||
)
|
||||
const canReturnRiskRule = computed(() => false)
|
||||
const riskRuleHasPublishableRevision = computed(() => {
|
||||
const revision = selectedSkill.value?.configJson?.revision_draft
|
||||
return selectedSkillUsesJsonRisk.value && revision &&
|
||||
revision.generation_status === 'completed' &&
|
||||
normalizeText(selectedSkill.value?.workingVersion).replace('-', '') &&
|
||||
selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
|
||||
})
|
||||
const canPublishRiskRule = computed(
|
||||
() =>
|
||||
false
|
||||
Boolean(riskRuleHasPublishableRevision.value) &&
|
||||
canManageSelected.value &&
|
||||
riskRuleTestPassed.value &&
|
||||
!detailBusy.value
|
||||
)
|
||||
const canToggleRiskRuleEnabled = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
|
||||
@@ -375,6 +381,7 @@ export default {
|
||||
canDeleteRiskRule,
|
||||
canReturnRiskRule,
|
||||
canPublishRiskRule,
|
||||
riskRuleHasPublishableRevision,
|
||||
canToggleRiskRuleEnabled,
|
||||
canEditRiskRuleDraft,
|
||||
canCreateRiskRuleRevision,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||
import { ElInput } from 'element-plus/es/components/input/index.mjs'
|
||||
import { ElPagination } from 'element-plus/es/components/pagination/index.mjs'
|
||||
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index.mjs'
|
||||
|
||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
|
||||
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { fetchBudgetSummary } from '../../services/budgets.js'
|
||||
import { fetchEmployeeMeta } from '../../services/employees.js'
|
||||
import {
|
||||
canEditBudgetCenter,
|
||||
@@ -17,10 +16,19 @@ import {
|
||||
} from '../../utils/accessControl.js'
|
||||
import {
|
||||
BUDGET_QUARTER_OPTIONS,
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||
BUDGET_YEAR_OPTIONS,
|
||||
resolveBudgetExpenseTypeLabel
|
||||
BUDGET_YEAR_OPTIONS
|
||||
} from '../../utils/budgetOntology.js'
|
||||
import {
|
||||
BUDGET_PAGE_SIZE_OPTIONS,
|
||||
BUDGET_SCOPE_ALL,
|
||||
BUDGET_SCOPE_ARCHIVE,
|
||||
BUDGET_SCOPE_REVIEW,
|
||||
buildBudgetRows,
|
||||
buildBudgetScopeTabs,
|
||||
buildBudgetUsageData,
|
||||
getBudgetStatusOptions,
|
||||
matchesBudgetKeyword
|
||||
} from './budgetCenterListModel.js'
|
||||
|
||||
const FALLBACK_DEPARTMENTS = [
|
||||
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
|
||||
@@ -31,182 +39,52 @@ const FALLBACK_DEPARTMENTS = [
|
||||
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
|
||||
]
|
||||
|
||||
const EXPENSE_BUDGET_SEED = {
|
||||
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
|
||||
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
|
||||
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
|
||||
office: { total: 180000, used: 68500, occupied: 32000, warning: 70, action: '正常' }
|
||||
function mapOptions(values, suffix = '') {
|
||||
return values.map((value) => ({
|
||||
label: suffix ? `${value}${suffix}` : value,
|
||||
value
|
||||
}))
|
||||
}
|
||||
|
||||
const DEFAULT_EXPENSE_BUDGET = {
|
||||
total: 100000,
|
||||
used: 0,
|
||||
occupied: 0,
|
||||
warning: 70,
|
||||
action: '正常'
|
||||
function resolveBudgetUpdatedAt(row) {
|
||||
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
|
||||
}
|
||||
|
||||
const EXPENSE_BLUEPRINTS = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option) => ({
|
||||
...DEFAULT_EXPENSE_BUDGET,
|
||||
...EXPENSE_BUDGET_SEED[option.value],
|
||||
budgetSubjectCode: option.value,
|
||||
expenseType: option.label
|
||||
}))
|
||||
|
||||
const currency = (value) =>
|
||||
Number(value || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
|
||||
const comparison = (value, direction) => ({
|
||||
value,
|
||||
tone: direction === 'down' ? 'down' : 'up',
|
||||
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
|
||||
})
|
||||
|
||||
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
|
||||
const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
const BUDGET_COMPILED_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
|
||||
const normalizePeriodKey = (year, quarter) => {
|
||||
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
|
||||
const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim())
|
||||
? String(quarter || '').trim()
|
||||
: BUDGET_QUARTER_OPTIONS[0]
|
||||
return `${normalizedYear}${normalizedQuarter}`
|
||||
function resolveBudgetCompiler(row) {
|
||||
return row?.compiler || row?.owner || '-'
|
||||
}
|
||||
|
||||
const parsePercent = (value, fallback = 80) => {
|
||||
const parsed = Number(String(value || '').replace(/[^\d.-]/g, ''))
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
const clampPercent = (value) => Math.min(100, Math.max(0, Number(value) || 0))
|
||||
|
||||
function buildThresholds(warning) {
|
||||
const alert = clampPercent(warning)
|
||||
return {
|
||||
reminder: clampPercent(alert - 10),
|
||||
alert,
|
||||
risk: clampPercent(alert + 10)
|
||||
}
|
||||
}
|
||||
|
||||
function formatBudgetCompiledAt(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return BUDGET_COMPILED_TIME_FORMATTER.format(date).replace(/\//g, '-')
|
||||
}
|
||||
|
||||
function resolveBudgetCompiler(item) {
|
||||
return String(
|
||||
item?.compiler
|
||||
|| item?.compiled_by
|
||||
|| item?.compiledBy
|
||||
|| item?.created_by
|
||||
|| item?.createdBy
|
||||
|| item?.owner_name
|
||||
|| item?.ownerName
|
||||
|| '预算编制助手'
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveBudgetReviewer(item) {
|
||||
return String(
|
||||
item?.reviewer
|
||||
|| item?.reviewed_by
|
||||
|| item?.reviewedBy
|
||||
|| item?.approved_by
|
||||
|| item?.approvedBy
|
||||
|| item?.auditor
|
||||
|| item?.updated_by
|
||||
|| item?.updatedBy
|
||||
|| '高级财务人员'
|
||||
).trim()
|
||||
}
|
||||
|
||||
function normalizeBudgetAllocationRow(item) {
|
||||
const balance = item?.balance || {}
|
||||
const totalAmount = Number(balance.total_amount ?? item?.original_amount ?? 0)
|
||||
const usedAmount = Number(balance.consumed_amount ?? 0)
|
||||
const occupiedAmount = Number(balance.reserved_amount ?? 0)
|
||||
const leftAmount = Number(balance.available_amount ?? 0)
|
||||
const rate = Number(balance.usage_rate ?? 0)
|
||||
const warning = parsePercent(item?.warning_threshold, 80)
|
||||
const thresholds = buildThresholds(warning)
|
||||
const budgetSubjectCode = String(item?.subject_code || '').trim()
|
||||
const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode)
|
||||
|
||||
return {
|
||||
allocationId: item?.id || '',
|
||||
budgetNo: item?.budget_no || '',
|
||||
budgetSubjectCode,
|
||||
compiledAt: formatBudgetCompiledAt(item?.created_at || item?.createdAt || item?.updated_at || item?.updatedAt),
|
||||
compiler: resolveBudgetCompiler(item),
|
||||
reviewer: resolveBudgetReviewer(item),
|
||||
expenseType,
|
||||
totalAmount,
|
||||
usedAmount,
|
||||
occupiedAmount,
|
||||
leftAmount,
|
||||
rate,
|
||||
rateTone: rate >= thresholds.risk ? 'danger' : rate >= thresholds.alert ? 'warn' : 'ok',
|
||||
reminderThreshold: thresholds.reminder,
|
||||
alertThreshold: thresholds.alert,
|
||||
riskThreshold: thresholds.risk,
|
||||
reminderLine: `${thresholds.reminder}%`,
|
||||
alertLine: `${thresholds.alert}%`,
|
||||
riskLine: `${thresholds.risk}%`,
|
||||
total: currency(totalAmount),
|
||||
used: currency(usedAmount),
|
||||
occupied: currency(occupiedAmount),
|
||||
left: currency(leftAmount)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBudgetUsageData(rows) {
|
||||
const source = Array.isArray(rows) ? rows : []
|
||||
return {
|
||||
labels: source.map((item) => item.expenseType || '未分类'),
|
||||
budget: source.map((item) => Number(item.totalAmount || 0)),
|
||||
used: source.map((item) => Number(item.usedAmount || 0)),
|
||||
occupied: source.map((item) => Number(item.occupiedAmount || 0)),
|
||||
available: source.map((item) => Math.max(Number(item.leftAmount || 0), 0))
|
||||
}
|
||||
}
|
||||
|
||||
function formatAlertDate(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return ALERT_DATE_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function normalizeBudgetWarning(item) {
|
||||
const subjectName = item?.subject_name || resolveBudgetExpenseTypeLabel(item?.subject_code, item?.subject_code)
|
||||
const departmentName = item?.department_name || ''
|
||||
const usageRate = Number(item?.usage_rate || 0)
|
||||
const warningThreshold = Number(item?.warning_threshold || 0)
|
||||
const tone = item?.severity === 'danger' ? 'danger' : 'warn'
|
||||
return {
|
||||
id: item?.allocation_id || `${departmentName}-${subjectName}-${item?.period_key || ''}`,
|
||||
title: departmentName ? `${departmentName} · ${subjectName}` : subjectName,
|
||||
desc: item?.message || `使用率已达 ${usageRate}%,达到预警线 ${warningThreshold}%。`,
|
||||
date: formatAlertDate(item?.occurred_at),
|
||||
tone
|
||||
}
|
||||
function buildBudgetDetailKpis(row) {
|
||||
return [
|
||||
{
|
||||
label: '编制人',
|
||||
value: resolveBudgetCompiler(row),
|
||||
unit: '',
|
||||
meta: row.scope === BUDGET_SCOPE_REVIEW ? '提交草案' : '预算编制',
|
||||
color: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '审核人',
|
||||
value: row.reviewer || '-',
|
||||
unit: '',
|
||||
meta: '高级财务审核',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: '版本',
|
||||
value: row.version || '-',
|
||||
unit: '',
|
||||
meta: row.periodType || '预算版本',
|
||||
color: '#64748b'
|
||||
},
|
||||
{
|
||||
label: '更新时间',
|
||||
value: resolveBudgetUpdatedAt(row),
|
||||
unit: '',
|
||||
meta: '最近同步',
|
||||
color: '#f59e0b'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -217,91 +95,71 @@ export default {
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['openAssistant'],
|
||||
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
|
||||
components: {
|
||||
BudgetTrendChart,
|
||||
EnterpriseSelect,
|
||||
EnterpriseDetailCard,
|
||||
EnterpriseDetailPage,
|
||||
TableEmptyState,
|
||||
TableLoadingState,
|
||||
ElButton,
|
||||
ElInput,
|
||||
ElPagination,
|
||||
ElTable,
|
||||
ElTableColumn
|
||||
ElButton
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const departments = ref(FALLBACK_DEPARTMENTS)
|
||||
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
||||
const departmentKeyword = ref('')
|
||||
const activeBudgetScope = ref(BUDGET_SCOPE_ALL)
|
||||
const budgetKeyword = ref('')
|
||||
const budgetPage = ref(1)
|
||||
const budgetPageSize = ref(8)
|
||||
const budgetLoading = ref(true)
|
||||
const budgetError = ref('')
|
||||
const selectedBudgetId = ref('')
|
||||
const filters = ref({
|
||||
year: '2026',
|
||||
quarter: 'Q1',
|
||||
expenseType: '全部',
|
||||
status: '全部'
|
||||
})
|
||||
const budgetPage = ref(1)
|
||||
const budgetPageSize = ref(5)
|
||||
const budgetTableKeyword = ref('')
|
||||
const budgetRows = ref([])
|
||||
const budgetSummary = ref(null)
|
||||
const budgetLoading = ref(true)
|
||||
const budgetError = ref('')
|
||||
|
||||
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
|
||||
const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser))
|
||||
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
||||
const isDepartmentBudgetMonitor = computed(
|
||||
() => isBudgetMonitorUser(props.currentUser) && !canSwitchDepartments.value && !isExecutiveUser(props.currentUser)
|
||||
)
|
||||
const yearOptions = BUDGET_YEAR_OPTIONS.map((year) => ({ label: `${year}年度`, value: year }))
|
||||
const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
const departmentOptions = computed(() =>
|
||||
departments.value.map((department) => ({
|
||||
label: department.name,
|
||||
value: department.code
|
||||
}))
|
||||
)
|
||||
|
||||
const activeDepartment = computed(() =>
|
||||
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
|
||||
)
|
||||
|
||||
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
|
||||
const currentUserDepartmentName = computed(() =>
|
||||
String(props.currentUser?.departmentName || props.currentUser?.department || '').trim()
|
||||
)
|
||||
const currentUserCostCenter = computed(() =>
|
||||
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
|
||||
)
|
||||
const departmentRows = computed(() => budgetRows.value)
|
||||
const filteredBudgetRows = computed(() => {
|
||||
const keyword = budgetTableKeyword.value.trim().toLowerCase()
|
||||
return departmentRows.value
|
||||
.filter((row) => {
|
||||
if (!keyword) return true
|
||||
return [
|
||||
row.compiledAt,
|
||||
row.compiler,
|
||||
row.reviewer,
|
||||
row.expenseType,
|
||||
row.total,
|
||||
row.used,
|
||||
row.occupied,
|
||||
row.left,
|
||||
`${row.rate}%`,
|
||||
row.reminderLine,
|
||||
row.alertLine,
|
||||
row.riskLine
|
||||
].some((value) => String(value || '').toLowerCase().includes(keyword))
|
||||
})
|
||||
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
|
||||
.filter((row) => {
|
||||
if (filters.value.status === '全部') return true
|
||||
if (filters.value.status === '预警') return row.rateTone === 'warn'
|
||||
if (filters.value.status === '管控') return row.rateTone === 'danger'
|
||||
return row.rateTone === 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
const yearOptions = mapOptions(BUDGET_YEAR_OPTIONS, '年度')
|
||||
const quarterOptions = mapOptions(BUDGET_QUARTER_OPTIONS)
|
||||
const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
|
||||
const budgetRowsByScope = computed(() =>
|
||||
buildBudgetRows({
|
||||
departments: departments.value,
|
||||
year: filters.value.year,
|
||||
quarter: filters.value.quarter
|
||||
})
|
||||
)
|
||||
|
||||
const budgetScopeTabs = computed(() => buildBudgetScopeTabs(budgetRowsByScope.value))
|
||||
const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || [])
|
||||
const activeScopeLabel = computed(
|
||||
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
|
||||
)
|
||||
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
|
||||
|
||||
const filteredBudgetRows = computed(() =>
|
||||
activeScopeRows.value
|
||||
.filter((row) => filters.value.status === '全部' || row.statusLabel === filters.value.status)
|
||||
.filter((row) => matchesBudgetKeyword(row, budgetKeyword.value))
|
||||
)
|
||||
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
|
||||
const totalBudgetPages = computed(() =>
|
||||
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
|
||||
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 8)))
|
||||
)
|
||||
const currentBudgetPage = computed(() =>
|
||||
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
|
||||
@@ -310,102 +168,112 @@ export default {
|
||||
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
|
||||
)
|
||||
const visibleBudgetRows = computed(() => {
|
||||
const pageSize = Number(budgetPageSize.value || 5)
|
||||
const pageSize = Number(budgetPageSize.value || 8)
|
||||
const start = (currentBudgetPage.value - 1) * pageSize
|
||||
return filteredBudgetRows.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const totals = computed(() => {
|
||||
const rows = departmentRows.value
|
||||
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
|
||||
const used = rows.reduce((sum, item) => sum + item.usedAmount, 0)
|
||||
const occupied = rows.reduce((sum, item) => sum + item.occupiedAmount, 0)
|
||||
const selectedBudget = computed(() =>
|
||||
activeScopeRows.value.find((row) => row.id === selectedBudgetId.value) || null
|
||||
)
|
||||
const detailMode = computed(() => Boolean(selectedBudget.value))
|
||||
const selectedBudgetUsageData = computed(() => buildBudgetUsageData(selectedBudget.value))
|
||||
const budgetDetailTopBarPayload = computed(() => {
|
||||
const row = selectedBudget.value
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
occupied,
|
||||
left: Math.max(total - used - occupied, 0)
|
||||
view: {
|
||||
eyebrow: '预算详情',
|
||||
title: `${row.departmentName} · ${row.periodLabel}`,
|
||||
desc: `${row.budgetNo} / ${row.version} · 仅覆盖差旅、通信、招待费、办公用品`
|
||||
},
|
||||
alerts: [],
|
||||
kpis: buildBudgetDetailKpis(row)
|
||||
}
|
||||
})
|
||||
const selectedBudgetStatusNotes = computed(() => {
|
||||
const row = selectedBudget.value
|
||||
if (!row) return []
|
||||
|
||||
const budgetMetrics = computed(() => [
|
||||
{
|
||||
label: '预算总额',
|
||||
value: `¥${currency(totals.value.total)}`,
|
||||
yoy: comparison('+8.42%', 'up'),
|
||||
mom: comparison('+2.16%', 'up'),
|
||||
tone: 'primary',
|
||||
icon: 'mdi mdi-wallet-outline'
|
||||
},
|
||||
{
|
||||
label: '已发生',
|
||||
value: `¥${currency(totals.value.used)}`,
|
||||
yoy: comparison('+12.68%', 'up'),
|
||||
mom: comparison('+4.35%', 'up'),
|
||||
tone: 'info',
|
||||
icon: 'mdi mdi-chart-line'
|
||||
},
|
||||
{
|
||||
label: '已占用',
|
||||
value: `¥${currency(totals.value.occupied)}`,
|
||||
yoy: comparison('+6.37%', 'up'),
|
||||
mom: comparison('-1.84%', 'down'),
|
||||
tone: 'warning',
|
||||
icon: 'mdi mdi-briefcase-check-outline'
|
||||
},
|
||||
{
|
||||
label: '剩余可用',
|
||||
value: `¥${currency(totals.value.left)}`,
|
||||
yoy: comparison('-3.26%', 'down'),
|
||||
mom: comparison('-2.08%', 'down'),
|
||||
tone: 'primary',
|
||||
icon: 'mdi mdi-cash'
|
||||
}
|
||||
])
|
||||
|
||||
const visibleDepartments = computed(() => {
|
||||
const keyword = departmentKeyword.value.trim()
|
||||
return departments.value
|
||||
.filter((item) => !keyword || item.name.includes(keyword) || item.code.includes(keyword))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
icon: item.code === activeDepartmentCode.value ? 'mdi mdi-account-group-outline' : 'mdi mdi-domain'
|
||||
}))
|
||||
return [
|
||||
{
|
||||
label: '预算状态',
|
||||
value: row.statusLabel,
|
||||
tone: row.statusTone || 'ok',
|
||||
desc: row.auditSummary || '当前预算状态已完成同步,可在预算中心继续追踪。'
|
||||
},
|
||||
{
|
||||
label: '风险状态',
|
||||
value: row.riskLabel,
|
||||
tone: row.riskTone || 'ok',
|
||||
desc: `当前已发生与已占用合计使用率为 ${row.usageRateLabel},系统按四类费用的提醒、告警和风险阈值综合判断。`
|
||||
}
|
||||
]
|
||||
})
|
||||
const showTable = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length > 0)
|
||||
const showEmpty = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length === 0)
|
||||
const emptyState = computed(() => ({
|
||||
eyebrow: activeScopeLabel.value,
|
||||
title: `暂无${activeScopeLabel.value}`,
|
||||
desc: '当前筛选条件下没有匹配的预算记录。',
|
||||
icon: 'mdi mdi-database-search-outline',
|
||||
tone: 'blue',
|
||||
artLabel: '预算列表为空',
|
||||
tips: ['可以调整年度、季度、状态或关键词后重试。']
|
||||
}))
|
||||
const pageSummary = computed(() => `共 ${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} 页`)
|
||||
|
||||
const warnings = computed(() =>
|
||||
(Array.isArray(budgetSummary.value?.warnings) ? budgetSummary.value.warnings : [])
|
||||
.map(normalizeBudgetWarning)
|
||||
)
|
||||
|
||||
const budgetUsageData = computed(() =>
|
||||
normalizeBudgetUsageData(departmentRows.value)
|
||||
)
|
||||
|
||||
function openBudgetAssistant() {
|
||||
function openBudgetAssistant(prompt = '') {
|
||||
if (!canEditBudget.value) return
|
||||
emit('openAssistant', {
|
||||
source: 'budget',
|
||||
sessionType: 'budget',
|
||||
prompt: '',
|
||||
prompt,
|
||||
files: [],
|
||||
conversation: null
|
||||
})
|
||||
}
|
||||
|
||||
function openBudgetReviewAssistant(row) {
|
||||
if (!row || !canAuditBudgetDrafts.value) {
|
||||
openBudgetDetail(row)
|
||||
return
|
||||
}
|
||||
|
||||
openBudgetAssistant(
|
||||
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`
|
||||
)
|
||||
}
|
||||
|
||||
function openBudgetDetail(row) {
|
||||
if (!row?.id) return
|
||||
selectedBudgetId.value = row.id
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
selectedBudgetId.value = ''
|
||||
}
|
||||
|
||||
function handleRowAction(row) {
|
||||
if (activeBudgetScope.value === BUDGET_SCOPE_REVIEW && canAuditBudgetDrafts.value) {
|
||||
openBudgetReviewAssistant(row)
|
||||
return
|
||||
}
|
||||
openBudgetDetail(row)
|
||||
}
|
||||
|
||||
function goToBudgetPage(page) {
|
||||
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
||||
}
|
||||
|
||||
function changeBudgetPage(direction) {
|
||||
goToBudgetPage(currentBudgetPage.value + direction)
|
||||
function changeBudgetPageSize(size) {
|
||||
budgetPageSize.value = Number(size) || 8
|
||||
budgetPage.value = 1
|
||||
}
|
||||
|
||||
function resolveScopedDepartments(options) {
|
||||
if (!isDepartmentBudgetMonitor.value) {
|
||||
return options
|
||||
}
|
||||
if (!isDepartmentBudgetMonitor.value) return options
|
||||
|
||||
const userDepartment = currentUserDepartmentName.value
|
||||
const userCostCenter = currentUserCostCenter.value
|
||||
@@ -414,9 +282,7 @@ export default {
|
||||
return userDepartment && item.name === userDepartment
|
||||
})
|
||||
|
||||
if (scoped.length) {
|
||||
return scoped
|
||||
}
|
||||
if (scoped.length) return scoped
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -430,6 +296,7 @@ export default {
|
||||
|
||||
async function loadDepartments() {
|
||||
budgetLoading.value = true
|
||||
budgetError.value = ''
|
||||
try {
|
||||
const payload = await fetchEmployeeMeta()
|
||||
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
|
||||
@@ -442,39 +309,11 @@ export default {
|
||||
costCenter: String(item.costCenter || '')
|
||||
}))
|
||||
const scopedDepartments = resolveScopedDepartments(nextDepartments)
|
||||
|
||||
if (scopedDepartments.length) {
|
||||
departments.value = scopedDepartments
|
||||
if (!scopedDepartments.some((item) => item.code === activeDepartmentCode.value)) {
|
||||
activeDepartmentCode.value = scopedDepartments[0].code
|
||||
}
|
||||
}
|
||||
await loadBudgetData()
|
||||
} catch (error) {
|
||||
console.warn('Failed to load budget departments from employee meta:', error)
|
||||
await loadBudgetData()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBudgetData() {
|
||||
const department = activeDepartment.value || {}
|
||||
budgetLoading.value = true
|
||||
budgetError.value = ''
|
||||
try {
|
||||
const payload = await fetchBudgetSummary({
|
||||
year: filters.value.year,
|
||||
period: normalizePeriodKey(filters.value.year, filters.value.quarter),
|
||||
department_id: department.id || '',
|
||||
cost_center: department.costCenter || ''
|
||||
})
|
||||
const allocations = Array.isArray(payload?.allocations) ? payload.allocations : []
|
||||
budgetSummary.value = payload || null
|
||||
budgetRows.value = allocations.map(normalizeBudgetAllocationRow)
|
||||
} catch (error) {
|
||||
budgetError.value = error?.message || 'Failed to load budget data'
|
||||
budgetSummary.value = null
|
||||
budgetRows.value = []
|
||||
console.warn('Failed to load budget data:', error)
|
||||
} finally {
|
||||
budgetLoading.value = false
|
||||
}
|
||||
@@ -485,24 +324,24 @@ export default {
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
activeDepartmentCode,
|
||||
budgetPageSize,
|
||||
() => filters.value.year,
|
||||
() => filters.value.quarter,
|
||||
() => filters.value.expenseType,
|
||||
() => filters.value.status,
|
||||
budgetTableKeyword
|
||||
],
|
||||
() => activeBudgetScope.value,
|
||||
() => {
|
||||
filters.value.status = '全部'
|
||||
budgetPage.value = 1
|
||||
selectedBudgetId.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
[activeDepartmentCode, () => filters.value.year, () => filters.value.quarter],
|
||||
[
|
||||
budgetPageSize,
|
||||
budgetKeyword,
|
||||
() => filters.value.year,
|
||||
() => filters.value.quarter,
|
||||
() => filters.value.status
|
||||
],
|
||||
() => {
|
||||
void loadBudgetData()
|
||||
budgetPage.value = 1
|
||||
}
|
||||
)
|
||||
|
||||
@@ -512,37 +351,51 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
watch(detailMode, (value) => {
|
||||
emit('detail-open-change', value)
|
||||
}, { immediate: true })
|
||||
|
||||
watch(budgetDetailTopBarPayload, (payload) => {
|
||||
emit('detail-topbar-change', payload)
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
return {
|
||||
activeDepartmentCode,
|
||||
activeDepartmentName,
|
||||
BUDGET_SCOPE_ALL,
|
||||
BUDGET_SCOPE_ARCHIVE,
|
||||
BUDGET_SCOPE_REVIEW,
|
||||
activeBudgetScope,
|
||||
budgetError,
|
||||
budgetKeyword,
|
||||
budgetLoading,
|
||||
budgetMetrics,
|
||||
budgetPage: currentBudgetPage,
|
||||
budgetPageNumbers,
|
||||
budgetPageSize,
|
||||
budgetPageSizeOptions,
|
||||
budgetTableKeyword,
|
||||
budgetScopeTabs,
|
||||
backToList,
|
||||
canAuditBudgetDrafts,
|
||||
canEditBudget,
|
||||
canSwitchDepartments,
|
||||
changeBudgetPage,
|
||||
departmentKeyword,
|
||||
departments,
|
||||
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
||||
changeBudgetPageSize,
|
||||
detailMode,
|
||||
emptyState,
|
||||
filters,
|
||||
openBudgetAssistant,
|
||||
quarters: BUDGET_QUARTER_OPTIONS,
|
||||
departmentOptions,
|
||||
statuses: ['全部', '正常', '预警', '管控'],
|
||||
goToBudgetPage,
|
||||
handleRowAction,
|
||||
openBudgetAssistant,
|
||||
openBudgetDetail,
|
||||
openBudgetReviewAssistant,
|
||||
pageSummary,
|
||||
quarterOptions,
|
||||
selectedBudget,
|
||||
selectedBudgetStatusNotes,
|
||||
selectedBudgetUsageData,
|
||||
showEmpty,
|
||||
showTable,
|
||||
statusOptions,
|
||||
totalBudgetPages,
|
||||
totalBudgetRows,
|
||||
budgetUsageData,
|
||||
visibleBudgetRows,
|
||||
visibleDepartments,
|
||||
warnings,
|
||||
yearOptions,
|
||||
years: BUDGET_YEAR_OPTIONS
|
||||
yearOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,13 +169,17 @@ export default {
|
||||
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
|
||||
context_json: {
|
||||
role_codes: currentUser.value?.roleCodes || [],
|
||||
is_admin: Boolean(currentUser.value?.isAdmin),
|
||||
name: currentUser.value?.name || '',
|
||||
role: currentUser.value?.role || '',
|
||||
position: currentUser.value?.position || '',
|
||||
grade: currentUser.value?.grade || ''
|
||||
}
|
||||
})
|
||||
is_admin: Boolean(currentUser.value?.isAdmin),
|
||||
name: currentUser.value?.name || '',
|
||||
role: currentUser.value?.role || '',
|
||||
department: currentUser.value?.department || currentUser.value?.departmentName || '',
|
||||
department_name: currentUser.value?.departmentName || currentUser.value?.department || '',
|
||||
position: currentUser.value?.position || '',
|
||||
grade: currentUser.value?.grade || '',
|
||||
employee_no: currentUser.value?.employeeNo || '',
|
||||
manager_name: currentUser.value?.managerName || currentUser.value?.manager_name || ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
semanticResult.value = null
|
||||
semanticError.value = error.message || '语义解析失败,请稍后重试。'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
|
||||
import AgentTraceCenterView from '../AgentTraceCenterView.vue'
|
||||
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
|
||||
import LogDetailView from '../LogDetailView.vue'
|
||||
import LogsView from '../LogsView.vue'
|
||||
@@ -9,6 +10,7 @@ import { useSettings } from '../../composables/useSettings.js'
|
||||
export default {
|
||||
name: 'SettingsView',
|
||||
components: {
|
||||
AgentTraceCenterView,
|
||||
HermesEmployeeSettingsPanel,
|
||||
EnterpriseSelect,
|
||||
LlmSettingsPanel,
|
||||
|
||||
@@ -556,7 +556,7 @@ export default {
|
||||
emits: ['close', 'draft-saved', 'request-updated'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const { currentUser } = useSystemState()
|
||||
const { currentUser, refreshCurrentUserFromBackend } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
const fileInputRef = ref(null)
|
||||
@@ -1067,6 +1067,7 @@ export default {
|
||||
createMessage,
|
||||
currentInsight,
|
||||
currentUser,
|
||||
refreshCurrentUserFromBackend,
|
||||
draftClaimId,
|
||||
extractReviewAttachmentNames,
|
||||
failCurrentFlowStep,
|
||||
@@ -1149,6 +1150,7 @@ export default {
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer: submitComposerInternal,
|
||||
currentUser,
|
||||
refreshCurrentUserFromBackend,
|
||||
toast
|
||||
})
|
||||
function openTravelCalculator() {
|
||||
@@ -1493,7 +1495,7 @@ export default {
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
return
|
||||
}
|
||||
if (handleGuidedShortcut(shortcut)) {
|
||||
if (await handleGuidedShortcut(shortcut)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
|
||||
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
||||
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
||||
import EmployeeProfileRiskCard from '../../components/travel/EmployeeProfileRiskCard.vue'
|
||||
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
|
||||
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
|
||||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||||
import {
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
deleteExpenseClaimItem,
|
||||
deleteExpenseClaimItemAttachment,
|
||||
deleteExpenseClaim,
|
||||
fetchEmployeeLatestProfile,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimItemAttachmentPreview,
|
||||
preReviewExpenseClaim,
|
||||
returnExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
isCurrentRequestApplicant,
|
||||
isFinanceUser
|
||||
} from '../../utils/accessControl.js'
|
||||
import {
|
||||
buildRiskViewerContext,
|
||||
filterRiskCardsForVisibility
|
||||
} from '../../utils/riskVisibility.js'
|
||||
import {
|
||||
buildLeaderApprovalEvents,
|
||||
buildLeaderApprovalInfo,
|
||||
@@ -52,6 +56,7 @@ import {
|
||||
buildClaimSummaryRiskCards,
|
||||
buildItemClaimRiskState,
|
||||
extractRiskTagsFromText,
|
||||
filterRiskCardsByBusinessStage,
|
||||
normalizeRiskTone,
|
||||
resolveRiskTags
|
||||
} from './travelRequestDetailInsights.js'
|
||||
@@ -78,6 +83,17 @@ import {
|
||||
resolveExpenseReasonPlaceholder,
|
||||
resolveExpenseUploadHint
|
||||
} from './travelRequestDetailExpenseModel.js'
|
||||
import {
|
||||
buildAiPreReviewSnapshot,
|
||||
findLatestAiPreReviewEvent,
|
||||
isAiPreReviewFlag,
|
||||
isAiPreReviewPassed,
|
||||
resolveAiPreReviewToast,
|
||||
resolveSubmitActionIcon,
|
||||
resolveSubmitActionLabel,
|
||||
resolveSubmitConfirmDescription,
|
||||
resolveSubmitConfirmText
|
||||
} from './travelRequestDetailPreReviewModel.js'
|
||||
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
|
||||
|
||||
/*
|
||||
@@ -377,7 +393,7 @@ export default {
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect,
|
||||
EmployeeProfileRiskCard,
|
||||
StageRiskAdviceCard,
|
||||
RiskObservationEvidenceCard,
|
||||
TravelRequestApprovalDialog,
|
||||
TravelRequestBudgetAnalysis,
|
||||
@@ -410,6 +426,8 @@ export default {
|
||||
const deletingExpenseId = ref('')
|
||||
const pendingUploadExpenseId = ref('')
|
||||
const submitBusy = ref(false)
|
||||
const aiPreReviewSnapshot = ref(null)
|
||||
const riskFlagPreviewSnapshot = ref(null)
|
||||
const submitConfirmDialogOpen = ref(false)
|
||||
const riskOverrideDialogOpen = ref(false)
|
||||
const riskOverrideBusy = ref(false)
|
||||
@@ -441,10 +459,6 @@ export default {
|
||||
})
|
||||
const detailNoteEditor = ref('')
|
||||
const savingDetailNote = ref(false)
|
||||
const employeeRiskProfile = ref(null)
|
||||
const employeeRiskProfileLoading = ref(false)
|
||||
const employeeRiskProfileError = ref('')
|
||||
let employeeRiskProfileLoadSeq = 0
|
||||
|
||||
const request = computed(() => {
|
||||
const normalized = normalizeRequestForUi(props.request)
|
||||
@@ -496,7 +510,10 @@ export default {
|
||||
if (isArchivedRequest.value) {
|
||||
return canDeleteArchivedExpenseClaims(currentUser.value)
|
||||
}
|
||||
return isEditableRequest.value || canManageCurrentClaim.value
|
||||
if (canManageCurrentClaim.value) {
|
||||
return true
|
||||
}
|
||||
return isEditableRequest.value && isCurrentApplicant.value
|
||||
})
|
||||
const isDirectManagerApprovalStage = computed(() => {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
@@ -533,29 +550,6 @@ export default {
|
||||
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
||||
&& !isCurrentApplicant.value
|
||||
))
|
||||
const employeeProfileId = computed(() =>
|
||||
String(
|
||||
request.value.employeeId
|
||||
|| request.value.employee_id
|
||||
|| request.value.profileEmployeeId
|
||||
|| ''
|
||||
).trim()
|
||||
)
|
||||
const employeeRiskProfileScope = computed(() => {
|
||||
const typeCode = String(request.value.typeCode || request.value.expense_type || '').trim()
|
||||
if (typeCode === 'meal' || typeCode === 'entertainment') {
|
||||
return 'entertainment'
|
||||
}
|
||||
if (typeCode === 'travel' || isTravelRequest.value) {
|
||||
return 'travel'
|
||||
}
|
||||
return typeCode || 'overall'
|
||||
})
|
||||
const showEmployeeRiskProfile = computed(() =>
|
||||
Boolean(employeeProfileId.value)
|
||||
&& Boolean(request.value.claimId)
|
||||
&& !isDraftRequest.value
|
||||
)
|
||||
const canReturnRequest = computed(() => {
|
||||
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
|
||||
return false
|
||||
@@ -581,6 +575,25 @@ export default {
|
||||
|| canProcessBudgetApprovalStage.value
|
||||
)
|
||||
)
|
||||
const canViewApprovalRiskAdvice = computed(() => (
|
||||
Boolean(request.value.claimId)
|
||||
&& !isDraftRequest.value
|
||||
&& !isCurrentApplicant.value
|
||||
&& (canReturnRequest.value || canApproveRequest.value)
|
||||
))
|
||||
const showStageRiskAdvice = computed(() => canViewApprovalRiskAdvice.value)
|
||||
const riskViewerContext = computed(() => buildRiskViewerContext({
|
||||
request: request.value,
|
||||
currentUser: currentUser.value,
|
||||
businessStage: isApplicationDocument.value ? 'expense_application' : 'reimbursement',
|
||||
isApplicationDocument: isApplicationDocument.value,
|
||||
isCurrentApplicant: isCurrentApplicant.value,
|
||||
isBudgetReviewer: canProcessBudgetApprovalStage.value,
|
||||
isDirectManagerReviewer: isCurrentDirectManagerApprover.value,
|
||||
isFinanceReviewer: canProcessFinanceApprovalStage.value,
|
||||
isAdminViewer: canManageCurrentClaim.value,
|
||||
canViewApprovalRiskAdvice: canViewApprovalRiskAdvice.value
|
||||
}))
|
||||
const {
|
||||
canPayRequest,
|
||||
closePayConfirmDialog,
|
||||
@@ -628,7 +641,7 @@ export default {
|
||||
if (isBudgetApprovalStage.value) {
|
||||
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
|
||||
}
|
||||
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后会流转至预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
|
||||
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
|
||||
})
|
||||
const approvalConfirmBadge = computed(() => {
|
||||
if (isFinanceApprovalStage.value) {
|
||||
@@ -643,7 +656,7 @@ export default {
|
||||
if (isApplicationDocument.value) {
|
||||
return isBudgetApprovalStage.value
|
||||
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
|
||||
: '确认后该申请单会完成直属领导审批,并流转给预算管理者进一步审核。'
|
||||
: '确认后该申请单会完成直属领导审批,系统将按预算余额、当前风险和历史风险判断是否需要预算管理者复核;无风险且预算充足会直接完成申请并生成报销草稿。'
|
||||
}
|
||||
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||||
})
|
||||
@@ -666,7 +679,7 @@ export default {
|
||||
return isApplicationDocument.value
|
||||
? isBudgetApprovalStage.value
|
||||
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
|
||||
: `${request.value.id} 已确认审核,已流转至预算管理者审批。`
|
||||
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
|
||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||
})
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
@@ -713,6 +726,7 @@ export default {
|
||||
Object.keys(expenseAttachmentMeta).forEach((key) => {
|
||||
delete expenseAttachmentMeta[key]
|
||||
})
|
||||
aiPreReviewSnapshot.value = null
|
||||
closeAttachmentPreview()
|
||||
}
|
||||
pendingUploadExpenseId.value = ''
|
||||
@@ -724,19 +738,6 @@ export default {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
employeeProfileId.value,
|
||||
request.value.claimId,
|
||||
employeeRiskProfileScope.value,
|
||||
showEmployeeRiskProfile.value
|
||||
],
|
||||
() => {
|
||||
void loadEmployeeRiskProfile()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const heroFactItems = computed(() => [
|
||||
{
|
||||
key: 'document',
|
||||
@@ -846,6 +847,12 @@ export default {
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => request.value.claimId,
|
||||
() => {
|
||||
riskFlagPreviewSnapshot.value = null
|
||||
}
|
||||
)
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
@@ -907,7 +914,25 @@ export default {
|
||||
|
||||
function resolveClaimRiskFlags() {
|
||||
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
|
||||
return Array.isArray(flags) ? flags : []
|
||||
let requestFlags = Array.isArray(flags) ? flags : []
|
||||
const previewSnapshot = riskFlagPreviewSnapshot.value
|
||||
if (
|
||||
previewSnapshot
|
||||
&& previewSnapshot.claimId === request.value?.claimId
|
||||
&& Array.isArray(previewSnapshot.riskFlags)
|
||||
) {
|
||||
requestFlags = previewSnapshot.riskFlags
|
||||
}
|
||||
const snapshot = aiPreReviewSnapshot.value
|
||||
if (
|
||||
snapshot
|
||||
&& snapshot.claimId === request.value?.claimId
|
||||
&& Array.isArray(snapshot.riskFlags)
|
||||
&& !requestFlags.some(isAiPreReviewFlag)
|
||||
) {
|
||||
return snapshot.riskFlags
|
||||
}
|
||||
return requestFlags
|
||||
}
|
||||
|
||||
function resolveAttachmentDisplayName(item) {
|
||||
@@ -953,38 +978,6 @@ export default {
|
||||
return payload
|
||||
}
|
||||
|
||||
async function loadEmployeeRiskProfile() {
|
||||
const sequence = ++employeeRiskProfileLoadSeq
|
||||
employeeRiskProfileError.value = ''
|
||||
if (!showEmployeeRiskProfile.value) {
|
||||
employeeRiskProfile.value = null
|
||||
employeeRiskProfileLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
employeeRiskProfileLoading.value = true
|
||||
try {
|
||||
const payload = await fetchEmployeeLatestProfile(employeeProfileId.value, {
|
||||
scene: 'approval',
|
||||
claim_id: request.value.claimId,
|
||||
window_days: 90,
|
||||
expense_type_scope: employeeRiskProfileScope.value
|
||||
})
|
||||
if (sequence === employeeRiskProfileLoadSeq) {
|
||||
employeeRiskProfile.value = payload
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence === employeeRiskProfileLoadSeq) {
|
||||
employeeRiskProfile.value = null
|
||||
employeeRiskProfileError.value = error?.message || '画像读取失败,请稍后重试。'
|
||||
}
|
||||
} finally {
|
||||
if (sequence === employeeRiskProfileLoadSeq) {
|
||||
employeeRiskProfileLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canPreviewAttachment(item) {
|
||||
if (!item?.invoiceId) {
|
||||
return false
|
||||
@@ -1100,23 +1093,66 @@ export default {
|
||||
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
||||
}
|
||||
|
||||
function applyAiPreReviewPayload(payload) {
|
||||
aiPreReviewSnapshot.value = buildAiPreReviewSnapshot(payload, request.value.claimId)
|
||||
}
|
||||
|
||||
function applyClaimRiskFlagsPayload(payload) {
|
||||
const flags = Array.isArray(payload?.claim_risk_flags)
|
||||
? payload.claim_risk_flags
|
||||
: Array.isArray(payload?.claimRiskFlags)
|
||||
? payload.claimRiskFlags
|
||||
: null
|
||||
if (!flags) {
|
||||
return
|
||||
}
|
||||
riskFlagPreviewSnapshot.value = {
|
||||
claimId: request.value.claimId,
|
||||
riskFlags: flags
|
||||
}
|
||||
}
|
||||
|
||||
const requiresAiPreReview = computed(() => isEditableRequest.value && !isApplicationDocument.value)
|
||||
const aiPreReviewEvent = computed(() => findLatestAiPreReviewEvent(resolveClaimRiskFlags()))
|
||||
const hasAiPreReviewResult = computed(() => !requiresAiPreReview.value || Boolean(aiPreReviewEvent.value))
|
||||
const aiPreReviewPassed = computed(() =>
|
||||
isAiPreReviewPassed(aiPreReviewEvent.value, requiresAiPreReview.value)
|
||||
)
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = isEditableRequest.value
|
||||
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
: []
|
||||
const directRiskCards = buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: resolveClaimRiskFlags()
|
||||
})
|
||||
const currentBusinessStage = isApplicationDocument.value ? 'expense_application' : 'reimbursement'
|
||||
const directRiskCards = filterRiskCardsByBusinessStage(
|
||||
buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: resolveClaimRiskFlags(),
|
||||
businessStage: currentBusinessStage
|
||||
}),
|
||||
currentBusinessStage
|
||||
)
|
||||
const hasActionableRiskCards = directRiskCards.some(
|
||||
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
|
||||
)
|
||||
const riskCards = [
|
||||
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
|
||||
const summaryRiskCards = filterRiskCardsByBusinessStage(
|
||||
buildClaimSummaryRiskCards({
|
||||
...(request.value || {}),
|
||||
businessStage: currentBusinessStage
|
||||
}),
|
||||
currentBusinessStage
|
||||
)
|
||||
const optionalRiskCards = filterRiskCardsByBusinessStage(
|
||||
buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value),
|
||||
currentBusinessStage
|
||||
)
|
||||
const scopedRiskCards = [
|
||||
...(hasActionableRiskCards ? [] : summaryRiskCards),
|
||||
...directRiskCards,
|
||||
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
|
||||
...optionalRiskCards
|
||||
]
|
||||
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
|
||||
|
||||
return buildAiAdviceViewModel({
|
||||
completionItems,
|
||||
@@ -1124,13 +1160,54 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
|
||||
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
|
||||
const aiAdviceHint = computed(() => (
|
||||
isEditableRequest.value
|
||||
? '按建议顺序补齐信息或处理风险后,再发起审批。'
|
||||
: '展示系统已识别的风险点,便于审批和后续整改。'
|
||||
const hasVisibleRiskCards = computed(() =>
|
||||
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||
)
|
||||
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
|
||||
const showAiAdvicePanel = computed(() => (
|
||||
(
|
||||
isEditableRequest.value
|
||||
&& (
|
||||
(requiresAiPreReview.value && hasAiPreReviewResult.value)
|
||||
|| hasAdviceSections.value
|
||||
)
|
||||
)
|
||||
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|
||||
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
|
||||
))
|
||||
const aiAdviceTitle = computed(() => {
|
||||
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
|
||||
return '报销风险提示'
|
||||
}
|
||||
if (isEditableRequest.value && isApplicationDocument.value) {
|
||||
return '表单自查提示'
|
||||
}
|
||||
return isEditableRequest.value ? 'AI建议' : 'AI提示'
|
||||
})
|
||||
const aiAdviceHint = computed(() => (
|
||||
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
|
||||
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
|
||||
: isEditableRequest.value
|
||||
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : 'AI预审已完成,请按风险提示补充原因或进入下一步。')
|
||||
: '展示系统已识别的风险点,便于审批和后续整改。'
|
||||
))
|
||||
|
||||
const submitActionLabel = computed(() => {
|
||||
return resolveSubmitActionLabel({
|
||||
isApplicationDocument: isApplicationDocument.value,
|
||||
hasAiPreReviewResult: hasAiPreReviewResult.value,
|
||||
submitBusy: submitBusy.value
|
||||
})
|
||||
})
|
||||
const submitActionIcon = computed(() => resolveSubmitActionIcon({
|
||||
isApplicationDocument: isApplicationDocument.value,
|
||||
hasAiPreReviewResult: hasAiPreReviewResult.value
|
||||
}))
|
||||
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
|
||||
isApplicationDocument: isApplicationDocument.value,
|
||||
aiPreReviewPassed: aiPreReviewPassed.value
|
||||
}))
|
||||
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
|
||||
|
||||
const submitRiskWarnings = computed(() =>
|
||||
aiAdvice.value.riskCards
|
||||
@@ -1470,6 +1547,7 @@ export default {
|
||||
|
||||
try {
|
||||
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||||
applyClaimRiskFlagsPayload(payload)
|
||||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
||||
@@ -1519,6 +1597,7 @@ export default {
|
||||
deletingAttachmentId.value = item.id
|
||||
try {
|
||||
const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id)
|
||||
applyClaimRiskFlagsPayload(payload)
|
||||
delete expenseAttachmentMeta[item.id]
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
invoiceId: '',
|
||||
@@ -1672,7 +1751,22 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
async function runAiPreReview() {
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await preReviewExpenseClaim(request.value.claimId)
|
||||
applyAiPreReviewPayload(payload)
|
||||
const event = findLatestAiPreReviewEvent(payload?.risk_flags_json || [])
|
||||
toast(resolveAiPreReviewToast(event))
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || 'AI预审失败,请稍后重试。')
|
||||
} finally {
|
||||
submitBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
return
|
||||
@@ -1688,6 +1782,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
|
||||
await runAiPreReview()
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
@@ -1723,6 +1822,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
await runAiPreReview()
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
openRiskOverrideDialog()
|
||||
@@ -1862,6 +1967,14 @@ export default {
|
||||
approveConfirmDialogOpen.value = false
|
||||
}
|
||||
|
||||
function resolveApproveErrorMessage(error) {
|
||||
const message = String(error?.message || '').trim()
|
||||
if (message.includes('未找到同部门 P8 预算审批人')) {
|
||||
return '当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。'
|
||||
}
|
||||
return message || '审批通过失败,请稍后重试。'
|
||||
}
|
||||
|
||||
async function confirmApproveRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||||
@@ -1889,8 +2002,9 @@ export default {
|
||||
: approvalSuccessToast.value
|
||||
)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
emit('backToRequests')
|
||||
} catch (error) {
|
||||
toast(error?.message || '审批通过失败,请稍后重试。')
|
||||
toast(resolveApproveErrorMessage(error))
|
||||
} finally {
|
||||
approveBusy.value = false
|
||||
}
|
||||
@@ -1939,7 +2053,6 @@ export default {
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
||||
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
||||
employeeRiskProfile, employeeRiskProfileError, employeeRiskProfileLoading,
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
@@ -1957,9 +2070,9 @@ export default {
|
||||
requiresApprovalOpinion,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||
showBudgetAnalysis, showEmployeeRiskProfile,
|
||||
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
showBudgetAnalysis, showStageRiskAdvice,
|
||||
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
||||
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ function resolveRiskScoreCardColor(level) {
|
||||
return 'var(--theme-primary)'
|
||||
}
|
||||
|
||||
function resolveStateColor(tone, fallback = 'var(--theme-primary)') {
|
||||
const normalized = normalizeText(tone).toLowerCase()
|
||||
if (['active', 'success', 'online'].includes(normalized)) return 'var(--success)'
|
||||
if (['disabled', 'offline', 'draft'].includes(normalized)) return '#64748b'
|
||||
if (['failed', 'danger', 'critical', 'high'].includes(normalized)) return '#ef4444'
|
||||
if (['review', 'warning', 'medium'].includes(normalized)) return '#f59e0b'
|
||||
if (['generating', 'info'].includes(normalized)) return 'var(--theme-primary)'
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function buildAuditDetailTopBar({
|
||||
skill,
|
||||
usesJsonRiskRule = false
|
||||
@@ -35,6 +45,38 @@ export function buildAuditDetailTopBar({
|
||||
: 'up',
|
||||
color: resolveRiskScoreCardColor(scoreLevel)
|
||||
})
|
||||
kpis.push({
|
||||
label: '风险等级',
|
||||
value: normalizeText(skill.riskRuleSeverityLabel) || '待评估',
|
||||
unit: '',
|
||||
meta: normalizeText(skill.riskRuleScoreLabel) || '评分模型',
|
||||
trend: ['critical', 'high', 'medium'].includes(normalizeText(scoreLevel).toLowerCase()) ? 'down' : 'up',
|
||||
color: resolveRiskScoreCardColor(scoreLevel)
|
||||
})
|
||||
kpis.push({
|
||||
label: '规则状态',
|
||||
value: normalizeText(skill.status) || '待上线',
|
||||
unit: '',
|
||||
meta: normalizeText(skill.displayVersion) || '工作版本',
|
||||
trend: '',
|
||||
color: resolveStateColor(skill.statusTone)
|
||||
})
|
||||
kpis.push({
|
||||
label: '上线状态',
|
||||
value: normalizeText(skill.isOnlineLabel) || '待上线',
|
||||
unit: '',
|
||||
meta: normalizeText(skill.publishedAt) && skill.publishedAt !== '-' ? skill.publishedAt : '未发布',
|
||||
trend: skill.isOnlineValue ? 'up' : '',
|
||||
color: resolveStateColor(skill.isOnlineTone)
|
||||
})
|
||||
kpis.push({
|
||||
label: '启用状态',
|
||||
value: normalizeText(skill.isEnabledLabel) || '否',
|
||||
unit: '',
|
||||
meta: skill.isEnabledValue ? '参与扫描' : '不参与扫描',
|
||||
trend: skill.isEnabledValue ? 'up' : '',
|
||||
color: resolveStateColor(skill.isEnabledTone)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -209,6 +209,7 @@ export const RULE_TAB_TAG_ALIASES = {
|
||||
|
||||
export const RISK_SCENARIO_OPTIONS = [
|
||||
{ value: '', label: '全部场景' },
|
||||
{ value: '全部', label: '全部' },
|
||||
{ value: '差旅费', label: '差旅费' },
|
||||
{ value: '住宿费', label: '住宿费' },
|
||||
{ value: '交通费', label: '交通费' },
|
||||
|
||||
@@ -5,15 +5,11 @@ export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
|
||||
]
|
||||
|
||||
export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'hotel', label: '住宿费' },
|
||||
{ value: 'transport', label: '交通费' },
|
||||
{ value: 'meal', label: '业务招待费' },
|
||||
{ value: 'meeting', label: '会务费' },
|
||||
{ value: 'office', label: '办公用品费' },
|
||||
{ value: 'training', label: '培训费' },
|
||||
{ value: 'communication', label: '通讯费' },
|
||||
{ value: 'welfare', label: '福利费' }
|
||||
{ value: 'communication', label: '通信费' }
|
||||
]
|
||||
|
||||
export const RISK_RULE_BUSINESS_STAGE_OPTIONS = [
|
||||
@@ -55,7 +51,7 @@ export function createDefaultRiskRuleForm() {
|
||||
return {
|
||||
business_domain: 'expense',
|
||||
business_stage: 'reimbursement',
|
||||
expense_category: 'travel',
|
||||
expense_category: 'all',
|
||||
rule_title: '',
|
||||
requires_attachment: false,
|
||||
natural_language: ''
|
||||
|
||||
@@ -42,6 +42,7 @@ const LAST_OPERATION_LABELS = {
|
||||
test: '测试',
|
||||
online: '上线',
|
||||
offline: '下线',
|
||||
generation_failed: '生成失败',
|
||||
delete: '删除',
|
||||
update: '更新'
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './auditViewDataUtils.js'
|
||||
import { formatScenarioList } from './auditViewFormatters.js'
|
||||
const EXPENSE_TYPE_SCENARIO_LABELS = {
|
||||
all: '全部',
|
||||
travel: '差旅费',
|
||||
hotel: '住宿费',
|
||||
transport: '交通费',
|
||||
@@ -142,6 +143,10 @@ export function normalizeRiskScenarioCategory(value) {
|
||||
|
||||
export function normalizeExpenseTypeScenarioLabels(value) {
|
||||
const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : []
|
||||
if (values.some((item) => ['all', '*', 'overall', 'general', '全部', '通用'].includes(normalizeText(item).toLowerCase()))) {
|
||||
return ['全部']
|
||||
}
|
||||
|
||||
const labels = []
|
||||
const seen = new Set()
|
||||
|
||||
|
||||
@@ -105,6 +105,10 @@ function parseYear(rawText) {
|
||||
return match ? Number(match[1]) : 2026
|
||||
}
|
||||
|
||||
function hasExplicitYear(rawText) {
|
||||
return /(20\d{2})/.test(String(rawText || ''))
|
||||
}
|
||||
|
||||
function resolvePreviousPeriod(year, quarter) {
|
||||
if (quarter > 1) {
|
||||
return { year, quarter: quarter - 1 }
|
||||
@@ -117,35 +121,52 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) {
|
||||
return false
|
||||
}
|
||||
const text = normalizeBudgetText(rawText)
|
||||
const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText)
|
||||
return Boolean(
|
||||
text &&
|
||||
/(预算|budget)/.test(text) &&
|
||||
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
|
||||
parseQuarter(rawText)
|
||||
hasTargetPeriod
|
||||
)
|
||||
}
|
||||
|
||||
export function buildBudgetCompileReport(rawText, user = {}) {
|
||||
const targetYear = parseYear(rawText)
|
||||
const targetQuarter = parseQuarter(rawText) || 3
|
||||
const previous = resolvePreviousPeriod(targetYear, targetQuarter)
|
||||
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value, 0)
|
||||
const totalBudget = 1320000
|
||||
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget, 0)
|
||||
const parsedQuarter = parseQuarter(rawText)
|
||||
const isAnnualBudget = !parsedQuarter
|
||||
const targetQuarter = parsedQuarter || 1
|
||||
const previous = isAnnualBudget
|
||||
? { year: targetYear - 1, quarter: 0 }
|
||||
: resolvePreviousPeriod(targetYear, targetQuarter)
|
||||
const periodMultiplier = isAnnualBudget ? 4 : 1
|
||||
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0)
|
||||
const totalBudget = 1320000 * periodMultiplier
|
||||
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0)
|
||||
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
|
||||
|
||||
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
|
||||
const value = item.value * periodMultiplier
|
||||
const previousValue = item.previousValue * periodMultiplier
|
||||
const recommendedBudget = item.recommendedBudget * periodMultiplier
|
||||
const trendValue = item.previousValue
|
||||
? ((item.value - item.previousValue) / item.previousValue) * 100
|
||||
? ((value - previousValue) / previousValue) * 100
|
||||
: 0
|
||||
return {
|
||||
...item,
|
||||
amountDisplay: compactCurrency(item.value),
|
||||
display: percent(item.value, totalSpend),
|
||||
share: percent(item.value, totalSpend),
|
||||
value,
|
||||
previousValue,
|
||||
recommendedBudget,
|
||||
amountDisplay: compactCurrency(value),
|
||||
display: percent(value, totalSpend),
|
||||
share: percent(value, totalSpend),
|
||||
trend: `${trendValue >= 0 ? '+' : ''}${trendValue.toFixed(1)}%`,
|
||||
trendTone: trendValue >= 10 ? 'risk' : trendValue >= 0 ? 'warn' : 'stable',
|
||||
recommendedDisplay: compactCurrency(item.recommendedBudget)
|
||||
recommendedDisplay: compactCurrency(recommendedBudget),
|
||||
editableBudget: recommendedBudget,
|
||||
reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70,
|
||||
alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80,
|
||||
riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90,
|
||||
editNote: item.suggestion
|
||||
}
|
||||
})
|
||||
|
||||
@@ -158,13 +179,18 @@ export function buildBudgetCompileReport(rawText, user = {}) {
|
||||
|
||||
return {
|
||||
type: 'budget_compile_analysis',
|
||||
title: `${targetYear}年${targetQuarter}季度预算编制前置分析报告`,
|
||||
subtitle: `基于${previous.year}年${previous.quarter}季度预算执行模拟数据`,
|
||||
title: isAnnualBudget
|
||||
? `${targetYear}年度预算编制前置分析报告`
|
||||
: `${targetYear}年${targetQuarter}季度预算编制前置分析报告`,
|
||||
subtitle: isAnnualBudget
|
||||
? `基于${previous.year}年度预算执行模拟数据`
|
||||
: `基于${previous.year}年${previous.quarter}季度预算执行模拟数据`,
|
||||
departmentName,
|
||||
targetPeriod: `${targetYear}年${QUARTER_NAME_MAP[targetQuarter]}`,
|
||||
basePeriod: `${previous.year}年${QUARTER_NAME_MAP[previous.quarter]}`,
|
||||
targetPeriod: isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${QUARTER_NAME_MAP[targetQuarter]}`,
|
||||
basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}年${QUARTER_NAME_MAP[previous.quarter]}`,
|
||||
periodType: isAnnualBudget ? '年度预算' : '季度预算',
|
||||
centerValue: compactCurrency(totalSpend),
|
||||
centerLabel: '上季度开销',
|
||||
centerLabel: isAnnualBudget ? '去年开销' : '上季度开销',
|
||||
summary: {
|
||||
totalBudget: compactCurrency(totalBudget),
|
||||
totalSpend: compactCurrency(totalSpend),
|
||||
@@ -172,13 +198,25 @@ export function buildBudgetCompileReport(rawText, user = {}) {
|
||||
recommendedTotal: compactCurrency(recommendedTotal)
|
||||
},
|
||||
macroInsights: [
|
||||
`${previous.year}年${previous.quarter}季度实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
|
||||
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${targetYear}年${targetQuarter}季度预算编制的第一优先级。`,
|
||||
`${isAnnualBudget ? `${previous.year}年度` : `${previous.year}年${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
|
||||
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${targetQuarter}季度`}预算编制的第一优先级。`,
|
||||
`${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
|
||||
],
|
||||
items,
|
||||
editableDraft: {
|
||||
status: 'editing',
|
||||
rows: items.map((item) => ({
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
budgetAmount: item.editableBudget,
|
||||
reminderThreshold: item.reminderThreshold,
|
||||
alertThreshold: item.alertThreshold,
|
||||
riskThreshold: item.riskThreshold,
|
||||
note: item.editNote
|
||||
}))
|
||||
},
|
||||
recommendations: [
|
||||
`建议${targetYear}年${targetQuarter}季度总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
|
||||
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
|
||||
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
|
||||
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
|
||||
],
|
||||
|
||||
365
web/src/views/scripts/budgetCenterListModel.js
Normal file
365
web/src/views/scripts/budgetCenterListModel.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import {
|
||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||
formatBudgetPeriod
|
||||
} from '../../utils/budgetOntology.js'
|
||||
|
||||
export const BUDGET_SCOPE_ALL = 'all'
|
||||
export const BUDGET_SCOPE_REVIEW = 'review'
|
||||
export const BUDGET_SCOPE_ARCHIVE = 'archive'
|
||||
|
||||
export const BUDGET_SCOPE_TABS = Object.freeze([
|
||||
{ value: BUDGET_SCOPE_ALL, label: '全部预算' },
|
||||
{ value: BUDGET_SCOPE_REVIEW, label: '预算审核' },
|
||||
{ value: BUDGET_SCOPE_ARCHIVE, label: '归档预算' }
|
||||
])
|
||||
|
||||
export const BUDGET_PAGE_SIZE_OPTIONS = Object.freeze([8, 12, 20])
|
||||
|
||||
const STATUS_OPTIONS_BY_SCOPE = Object.freeze({
|
||||
[BUDGET_SCOPE_ALL]: ['全部', '正常', '预警', '管控'],
|
||||
[BUDGET_SCOPE_REVIEW]: ['全部', '待审核', '复核中', '待补充', '已驳回'],
|
||||
[BUDGET_SCOPE_ARCHIVE]: ['全部', '已归档', '已替换', '已驳回']
|
||||
})
|
||||
|
||||
const DEPARTMENT_PROFILE = Object.freeze({
|
||||
'MARKET-DEPT': { factor: 1.22, owner: '周明悦', reviewer: '陈思远', riskShift: 12 },
|
||||
'FINANCE-DEPT': { factor: 0.74, owner: '韩清', reviewer: '沈知行', riskShift: -6 },
|
||||
'TECH-DEPT': { factor: 1.08, owner: '林子昂', reviewer: '陈思远', riskShift: 4 },
|
||||
'HR-DEPT': { factor: 0.68, owner: '许婉', reviewer: '沈知行', riskShift: 2 },
|
||||
'PRODUCTION-DEPT': { factor: 1.36, owner: '赵屿', reviewer: '陈思远', riskShift: 8 },
|
||||
'PRESIDENT-OFFICE': { factor: 0.92, owner: '孟澜', reviewer: '沈知行', riskShift: -2 }
|
||||
})
|
||||
|
||||
const DEFAULT_PROFILE = Object.freeze({
|
||||
factor: 1,
|
||||
owner: '预算编制助手',
|
||||
reviewer: '高级财务人员',
|
||||
riskShift: 0
|
||||
})
|
||||
|
||||
const CATEGORY_SEED = Object.freeze({
|
||||
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80 },
|
||||
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70 },
|
||||
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80 },
|
||||
office: { total: 180000, used: 68500, occupied: 32000, warning: 70 }
|
||||
})
|
||||
|
||||
const QUARTER_FACTOR = Object.freeze({
|
||||
Q1: 0.92,
|
||||
Q2: 1,
|
||||
Q3: 1.12,
|
||||
Q4: 1.18
|
||||
})
|
||||
|
||||
const REVIEW_STATUS_SEQUENCE = ['待审核', '待审核', '复核中', '待补充', '待审核', '已驳回']
|
||||
const ARCHIVE_STATUS_SEQUENCE = ['已归档', '已替换', '已驳回', '已归档', '已替换', '已归档']
|
||||
|
||||
export function currency(value) {
|
||||
return Number(value || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
export function money(value) {
|
||||
return `¥${currency(value)}`
|
||||
}
|
||||
|
||||
export function getBudgetStatusOptions(scope) {
|
||||
return STATUS_OPTIONS_BY_SCOPE[scope] || STATUS_OPTIONS_BY_SCOPE[BUDGET_SCOPE_ALL]
|
||||
}
|
||||
|
||||
export function buildBudgetScopeTabs(rowsByScope) {
|
||||
return BUDGET_SCOPE_TABS.map((tab) => ({
|
||||
...tab,
|
||||
count: Array.isArray(rowsByScope?.[tab.value]) ? rowsByScope[tab.value].length : 0
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildBudgetRows({ departments = [], year = '2026', quarter = 'Q1' } = {}) {
|
||||
const scopedDepartments = Array.isArray(departments) ? departments : []
|
||||
return {
|
||||
[BUDGET_SCOPE_ALL]: scopedDepartments.map((department, index) =>
|
||||
buildActiveBudgetRow(department, index, { year, quarter })
|
||||
),
|
||||
[BUDGET_SCOPE_REVIEW]: scopedDepartments.map((department, index) =>
|
||||
buildReviewBudgetRow(department, index, { year, quarter })
|
||||
),
|
||||
[BUDGET_SCOPE_ARCHIVE]: scopedDepartments.map((department, index) =>
|
||||
buildArchiveBudgetRow(department, index, { year, quarter })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBudgetUsageData(row) {
|
||||
const categories = Array.isArray(row?.categoryRows) ? row.categoryRows : []
|
||||
return {
|
||||
labels: categories.map((item) => item.name),
|
||||
budget: categories.map((item) => item.amount),
|
||||
used: categories.map((item) => item.used),
|
||||
occupied: categories.map((item) => item.occupied),
|
||||
available: categories.map((item) => item.available)
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesBudgetKeyword(row, keyword) {
|
||||
const normalized = String(keyword || '').trim().toLowerCase()
|
||||
if (!normalized) return true
|
||||
return String(row?.searchText || '').includes(normalized)
|
||||
}
|
||||
|
||||
function buildActiveBudgetRow(department, index, context) {
|
||||
const profile = resolveProfile(department)
|
||||
const categoryRows = buildCategoryRows(department, index, context)
|
||||
const totals = summarizeCategories(categoryRows)
|
||||
const risk = resolveRisk(totals.usageRate)
|
||||
const periodLabel = formatBudgetPeriod(context.year, context.quarter)
|
||||
const budgetNo = `BUD-${context.year}-${department.code || index + 1}`
|
||||
|
||||
return enrichSearchText({
|
||||
id: `${budgetNo}-ACTIVE`,
|
||||
scope: BUDGET_SCOPE_ALL,
|
||||
budgetNo,
|
||||
departmentCode: department.code || '',
|
||||
departmentName: department.name || '当前部门',
|
||||
costCenter: department.costCenter || '',
|
||||
periodLabel,
|
||||
periodType: '季度预算',
|
||||
budgetYear: `${context.year}年度`,
|
||||
budgetQuarter: context.quarter,
|
||||
version: `V${index + 1}.0`,
|
||||
owner: profile.owner,
|
||||
reviewer: profile.reviewer,
|
||||
annualAmount: totals.annualAmount,
|
||||
quarterAmount: totals.total,
|
||||
monthAmount: totals.total / 3,
|
||||
usedAmount: totals.used,
|
||||
occupiedAmount: totals.occupied,
|
||||
availableAmount: totals.available,
|
||||
annualAmountLabel: money(totals.annualAmount),
|
||||
quarterAmountLabel: money(totals.total),
|
||||
monthAmountLabel: money(totals.total / 3),
|
||||
usedAmountLabel: money(totals.used),
|
||||
occupiedAmountLabel: money(totals.occupied),
|
||||
availableAmountLabel: money(totals.available),
|
||||
usageRate: totals.usageRate,
|
||||
usageRateLabel: `${totals.usageRate}%`,
|
||||
riskTone: risk.tone,
|
||||
riskLabel: risk.label,
|
||||
statusLabel: risk.tone === 'risk' ? '管控' : risk.tone === 'alert' ? '预警' : '正常',
|
||||
statusTone: risk.tone,
|
||||
updatedAt: `2026-05-${String(28 - index).padStart(2, '0')} 16:${String(20 + index).padStart(2, '0')}`,
|
||||
categoryRows,
|
||||
periodRows: buildPeriodRows(totals),
|
||||
auditSummary: '已通过高级财务审核并发布为正式预算。',
|
||||
actionLabel: '查看详情'
|
||||
})
|
||||
}
|
||||
|
||||
function buildReviewBudgetRow(department, index, context) {
|
||||
const activeRow = buildActiveBudgetRow(department, index, context)
|
||||
const profile = resolveProfile(department)
|
||||
const statusLabel = REVIEW_STATUS_SEQUENCE[index % REVIEW_STATUS_SEQUENCE.length]
|
||||
const applyFactor = 1 + (index % 3) * 0.06 + Math.max(profile.riskShift, 0) / 200
|
||||
const requestedAmount = activeRow.quarterAmount * applyFactor
|
||||
const changeRate = Number(((requestedAmount / activeRow.quarterAmount - 1) * 100).toFixed(1))
|
||||
const risk = resolveRisk(activeRow.usageRate + profile.riskShift)
|
||||
const categoryRows = activeRow.categoryRows.map((item) => ({
|
||||
...item,
|
||||
amount: Math.round(item.amount * applyFactor),
|
||||
amountLabel: money(Math.round(item.amount * applyFactor)),
|
||||
note: buildReviewNote(item.name, risk.tone)
|
||||
}))
|
||||
|
||||
return enrichSearchText({
|
||||
...activeRow,
|
||||
id: `${activeRow.budgetNo}-DRAFT`,
|
||||
scope: BUDGET_SCOPE_REVIEW,
|
||||
budgetNo: `DRF-${context.year}-${department.code || index + 1}`,
|
||||
periodType: '预算草案',
|
||||
version: `草案 V1.${index}`,
|
||||
compiler: profile.owner,
|
||||
submittedAt: `2026-05-${String(26 + (index % 3)).padStart(2, '0')} ${String(10 + index).padStart(2, '0')}:20`,
|
||||
requestedAmount,
|
||||
requestedAmountLabel: money(requestedAmount),
|
||||
previousAmountLabel: activeRow.quarterAmountLabel,
|
||||
changeRate,
|
||||
changeRateLabel: `${changeRate >= 0 ? '+' : ''}${changeRate}%`,
|
||||
aiScore: Math.max(68, Math.min(94, 88 - index * 2 - Math.max(profile.riskShift, 0))),
|
||||
riskTone: risk.tone,
|
||||
riskLabel: risk.label,
|
||||
statusLabel,
|
||||
statusTone: resolveReviewStatusTone(statusLabel),
|
||||
categoryRows,
|
||||
periodRows: buildPeriodRows({
|
||||
total: requestedAmount,
|
||||
annualAmount: requestedAmount * 4,
|
||||
used: activeRow.usedAmount,
|
||||
occupied: activeRow.occupiedAmount,
|
||||
available: Math.max(requestedAmount - activeRow.usedAmount - activeRow.occupiedAmount, 0),
|
||||
usageRate: percent(activeRow.usedAmount + activeRow.occupiedAmount, requestedAmount)
|
||||
}),
|
||||
auditSummary: '等待高级财务人员审核,审核通过后才能发布到正式预算中心。',
|
||||
actionLabel: '进入审核'
|
||||
})
|
||||
}
|
||||
|
||||
function buildArchiveBudgetRow(department, index, context) {
|
||||
const activeRow = buildActiveBudgetRow(department, index, context)
|
||||
const statusLabel = ARCHIVE_STATUS_SEQUENCE[index % ARCHIVE_STATUS_SEQUENCE.length]
|
||||
const archiveFactor = statusLabel === '已驳回' ? 0.96 : 0.9
|
||||
|
||||
return enrichSearchText({
|
||||
...activeRow,
|
||||
id: `${activeRow.budgetNo}-ARCHIVE`,
|
||||
scope: BUDGET_SCOPE_ARCHIVE,
|
||||
budgetNo: `ARC-${context.year}-${department.code || index + 1}`,
|
||||
version: `历史 V${Math.max(1, index)}.${index % 3}`,
|
||||
periodType: '历史预算',
|
||||
archiveType: statusLabel === '已驳回' ? '审核驳回' : statusLabel === '已替换' ? '版本替换' : '周期归档',
|
||||
quarterAmount: activeRow.quarterAmount * archiveFactor,
|
||||
quarterAmountLabel: money(activeRow.quarterAmount * archiveFactor),
|
||||
reviewer: resolveProfile(department).reviewer,
|
||||
archivedAt: `2026-05-${String(12 + index).padStart(2, '0')} 18:00`,
|
||||
statusLabel,
|
||||
statusTone: statusLabel === '已驳回' ? 'risk' : 'archived',
|
||||
auditSummary: statusLabel === '已驳回'
|
||||
? '该预算版本未通过审核,已保留驳回记录。'
|
||||
: '该预算版本已进入历史归档,可用于审计追溯。',
|
||||
actionLabel: '查看归档'
|
||||
})
|
||||
}
|
||||
|
||||
function buildCategoryRows(department, index, context) {
|
||||
const profile = resolveProfile(department)
|
||||
const quarterFactor = QUARTER_FACTOR[context.quarter] || 1
|
||||
const usageShift = 1 + profile.riskShift / 100
|
||||
return BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option, categoryIndex) => {
|
||||
const seed = CATEGORY_SEED[option.value] || { total: 100000, used: 0, occupied: 0, warning: 70 }
|
||||
const amount = Math.round(seed.total * profile.factor * quarterFactor * (1 + categoryIndex * 0.015))
|
||||
const used = Math.round(seed.used * profile.factor * usageShift)
|
||||
const occupied = Math.round(seed.occupied * profile.factor * Math.max(0.8, usageShift))
|
||||
const available = Math.max(amount - used - occupied, 0)
|
||||
const usageRate = percent(used + occupied, amount)
|
||||
const thresholds = buildThresholds(seed.warning)
|
||||
const risk = resolveRisk(usageRate, thresholds)
|
||||
|
||||
return {
|
||||
code: option.value,
|
||||
name: option.label,
|
||||
amount,
|
||||
used,
|
||||
occupied,
|
||||
available,
|
||||
amountLabel: money(amount),
|
||||
usedLabel: money(used),
|
||||
occupiedLabel: money(occupied),
|
||||
availableLabel: money(available),
|
||||
usageRate,
|
||||
usageRateLabel: `${usageRate}%`,
|
||||
reminderLine: `${thresholds.reminder}%`,
|
||||
alertLine: `${thresholds.alert}%`,
|
||||
riskLine: `${thresholds.risk}%`,
|
||||
riskTone: risk.tone,
|
||||
riskLabel: risk.label,
|
||||
note: buildCategoryNote(option.label, risk.tone, index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildPeriodRows(totals) {
|
||||
return [
|
||||
{ label: '年度预算', value: money(totals.annualAmount), desc: '按四类费用预算汇总' },
|
||||
{ label: '季度预算', value: money(totals.total), desc: '当前列表筛选周期' },
|
||||
{ label: '月度预算', value: money(totals.total / 3), desc: '按季度预算月均拆分' }
|
||||
]
|
||||
}
|
||||
|
||||
function summarizeCategories(rows) {
|
||||
const total = rows.reduce((sum, item) => sum + item.amount, 0)
|
||||
const used = rows.reduce((sum, item) => sum + item.used, 0)
|
||||
const occupied = rows.reduce((sum, item) => sum + item.occupied, 0)
|
||||
const available = Math.max(total - used - occupied, 0)
|
||||
return {
|
||||
total,
|
||||
annualAmount: total * 4,
|
||||
used,
|
||||
occupied,
|
||||
available,
|
||||
usageRate: percent(used + occupied, total)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfile(department) {
|
||||
return {
|
||||
...DEFAULT_PROFILE,
|
||||
...(DEPARTMENT_PROFILE[department?.code] || {})
|
||||
}
|
||||
}
|
||||
|
||||
function percent(value, total) {
|
||||
if (!Number(total)) return 0
|
||||
return Number(((Number(value || 0) / Number(total)) * 100).toFixed(1))
|
||||
}
|
||||
|
||||
function buildThresholds(warning) {
|
||||
const alert = clampPercent(warning)
|
||||
return {
|
||||
reminder: clampPercent(alert - 10),
|
||||
alert,
|
||||
risk: clampPercent(alert + 10)
|
||||
}
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
return Math.min(100, Math.max(0, Number(value) || 0))
|
||||
}
|
||||
|
||||
function resolveRisk(value, thresholds = { reminder: 70, alert: 80, risk: 90 }) {
|
||||
const rate = Number(value || 0)
|
||||
if (rate >= thresholds.risk) return { label: '风险', tone: 'risk' }
|
||||
if (rate >= thresholds.alert) return { label: '告警', tone: 'alert' }
|
||||
if (rate >= thresholds.reminder) return { label: '提醒', tone: 'reminder' }
|
||||
return { label: '正常', tone: 'ok' }
|
||||
}
|
||||
|
||||
function resolveReviewStatusTone(status) {
|
||||
if (status === '待补充') return 'alert'
|
||||
if (status === '已驳回') return 'risk'
|
||||
if (status === '复核中') return 'reminder'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function buildCategoryNote(name, tone, index) {
|
||||
if (tone === 'risk') return `${name}使用率已接近风险线,需要重点复核。`
|
||||
if (tone === 'alert') return `${name}超过告警线,建议核对业务计划和已占用金额。`
|
||||
if (tone === 'reminder') return `${name}接近提醒线,后续应持续观察。`
|
||||
return index % 2 === 0 ? `${name}预算执行稳定。` : `${name}仍在正常预算区间。`
|
||||
}
|
||||
|
||||
function buildReviewNote(name, tone) {
|
||||
if (tone === 'risk') return `${name}预算增幅较高,审核时需要补充业务依据。`
|
||||
if (tone === 'alert') return `${name}建议结合上一季度发生额复核。`
|
||||
return `${name}建议按部门编制说明核对。`
|
||||
}
|
||||
|
||||
function enrichSearchText(row) {
|
||||
const values = [
|
||||
row.budgetNo,
|
||||
row.departmentName,
|
||||
row.costCenter,
|
||||
row.periodLabel,
|
||||
row.periodType,
|
||||
row.version,
|
||||
row.owner,
|
||||
row.compiler,
|
||||
row.reviewer,
|
||||
row.statusLabel,
|
||||
row.riskLabel,
|
||||
row.archiveType
|
||||
]
|
||||
return {
|
||||
...row,
|
||||
searchText: values.filter(Boolean).join(' ').toLowerCase()
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const KNOWLEDGE_JOB_TYPES = new Set([
|
||||
const TASK_TYPE_LABELS = {
|
||||
global_risk_scan: '财务风险图谱巡检',
|
||||
employee_behavior_profile_scan: '员工行为画像巡检',
|
||||
risk_clue_collect: '风险线索归集',
|
||||
finance_policy_knowledge_organize: '知识制度整理',
|
||||
knowledge_index_sync: '知识制度整理',
|
||||
llm_wiki_sync: '知识制度整理',
|
||||
@@ -30,6 +31,7 @@ const TASK_TYPE_LABELS = {
|
||||
const TASK_CODE_TO_TYPE = {
|
||||
'task.hermes.global_risk_scan': 'global_risk_scan',
|
||||
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
|
||||
'task.hermes.risk_rule_discovery': 'risk_clue_collect',
|
||||
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
|
||||
}
|
||||
|
||||
@@ -56,6 +58,9 @@ function resolveTaskTypeFromToolName(value) {
|
||||
if (name.includes('finance_policy_knowledge')) {
|
||||
return 'finance_policy_knowledge_organize'
|
||||
}
|
||||
if (name.includes('risk_clue')) {
|
||||
return 'risk_clue_collect'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -136,6 +141,9 @@ export function resolveWorkRecordProductKind(run) {
|
||||
if (taskType === 'employee_behavior_profile_scan') {
|
||||
return 'employee_profile'
|
||||
}
|
||||
if (taskType === 'risk_clue_collect') {
|
||||
return 'risk_clue'
|
||||
}
|
||||
if (KNOWLEDGE_JOB_TYPES.has(taskType)) {
|
||||
return 'knowledge'
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ export function filterDigitalEmployees(items = [], filters = {}) {
|
||||
const searchText = normalizeText(filters.keyword).toLowerCase()
|
||||
const hasKeyword = Boolean(searchText)
|
||||
const hasStatus = Boolean(filters.selectedStatus)
|
||||
const selectedSkillCategory = normalizeText(filters.selectedSkillCategory)
|
||||
const hasSkillCategory = Boolean(selectedSkillCategory)
|
||||
const hasEnabled = Boolean(filters.selectedEnabledState)
|
||||
const hasExecutionMode = Boolean(filters.selectedExecutionMode)
|
||||
|
||||
@@ -168,6 +170,9 @@ export function filterDigitalEmployees(items = [], filters = {}) {
|
||||
if (hasStatus && item.statusValue !== filters.selectedStatus) {
|
||||
return false
|
||||
}
|
||||
if (hasSkillCategory && normalizeText(item.skillCategory) !== selectedSkillCategory) {
|
||||
return false
|
||||
}
|
||||
if (hasEnabled && (filters.selectedEnabledState === 'enabled') !== Boolean(item.isEnabledValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
131
web/src/views/scripts/overviewDigitalEmployeeDashboardModel.js
Normal file
131
web/src/views/scripts/overviewDigitalEmployeeDashboardModel.js
Normal file
@@ -0,0 +1,131 @@
|
||||
export const emptyDigitalEmployeeDashboard = {
|
||||
windowDays: 7,
|
||||
generatedAt: '',
|
||||
hasRealData: false,
|
||||
totals: {
|
||||
totalRuns: 0,
|
||||
successRuns: 0,
|
||||
failedRuns: 0,
|
||||
runningRuns: 0,
|
||||
toolCalls: 0,
|
||||
businessOutputs: 0,
|
||||
riskObservations: 0,
|
||||
riskClues: 0,
|
||||
profileSnapshots: 0,
|
||||
knowledgeDocuments: 0,
|
||||
successRate: 0,
|
||||
failureRate: 0
|
||||
},
|
||||
dailyWork: [],
|
||||
taskDistribution: [],
|
||||
categoryDistribution: [
|
||||
{ name: '积累', value: 0, count: 0, color: 'var(--chart-blue)', description: '沉淀画像、基线和反馈样本' },
|
||||
{ name: '升级', value: 0, count: 0, color: 'var(--chart-amber)', description: '输出待复核线索和优化建议' },
|
||||
{ name: '整理', value: 0, count: 0, color: 'var(--success)', description: '整理制度、条款、知识和样本' },
|
||||
{ name: '评估', value: 0, count: 0, color: 'var(--theme-primary)', description: '评估异常、风险和一致性' }
|
||||
],
|
||||
recentRuns: []
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeKpiMetrics(dashboard, formatNumberCompact) {
|
||||
const data = dashboard || emptyDigitalEmployeeDashboard
|
||||
const totals = data.totals || emptyDigitalEmployeeDashboard.totals
|
||||
const rows = [
|
||||
{
|
||||
label: '工作总数',
|
||||
value: formatNumberCompact(totals.totalRuns),
|
||||
changeText: `${data.windowDays || 7}天`,
|
||||
delta: '后台任务',
|
||||
trend: 'up',
|
||||
icon: 'mdi mdi-briefcase-clock-outline',
|
||||
accent: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '成功数量',
|
||||
value: formatNumberCompact(totals.successRuns),
|
||||
changeText: `${Number(totals.successRate || 0).toFixed(1)}%`,
|
||||
delta: '运行成功率',
|
||||
trend: 'up',
|
||||
icon: 'mdi mdi-check-decagram-outline',
|
||||
accent: 'var(--success)'
|
||||
},
|
||||
{
|
||||
label: '失败数量',
|
||||
value: formatNumberCompact(totals.failedRuns),
|
||||
changeText: `${Number(totals.failureRate || 0).toFixed(1)}%`,
|
||||
delta: '需排查',
|
||||
trend: Number(totals.failedRuns || 0) > 0 ? 'down' : 'up',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
accent: '#ef4444'
|
||||
},
|
||||
{
|
||||
label: '业务产出',
|
||||
value: formatNumberCompact(totals.businessOutputs),
|
||||
changeText: '累计',
|
||||
delta: '观察/线索/快照/文档',
|
||||
trend: 'up',
|
||||
icon: 'mdi mdi-chart-box-outline',
|
||||
accent: '#0f766e'
|
||||
},
|
||||
{
|
||||
label: '工具调用',
|
||||
value: formatNumberCompact(totals.toolCalls),
|
||||
changeText: '执行链',
|
||||
delta: '工具执行次数',
|
||||
trend: 'up',
|
||||
icon: 'mdi mdi-tools',
|
||||
accent: '#2563eb'
|
||||
},
|
||||
{
|
||||
label: '运行中',
|
||||
value: formatNumberCompact(totals.runningRuns),
|
||||
changeText: Number(totals.runningRuns || 0) > 0 ? '进行中' : '空闲',
|
||||
delta: '当前窗口',
|
||||
trend: Number(totals.runningRuns || 0) > 0 ? 'down' : 'up',
|
||||
icon: 'mdi mdi-progress-clock',
|
||||
accent: '#f59e0b'
|
||||
}
|
||||
]
|
||||
|
||||
return rows.map((item, index) => ({
|
||||
...item,
|
||||
displayValue: item.value,
|
||||
delay: index * 55
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeDailyRows(dashboard) {
|
||||
const rows = Array.isArray(dashboard?.dailyWork) ? dashboard.dailyWork : []
|
||||
return rows.map((item) => ({
|
||||
date: String(item.date || '').trim() || '-',
|
||||
total: Number(item.total || 0),
|
||||
success: Number(item.success || 0),
|
||||
failed: Number(item.failed || 0),
|
||||
running: Number(item.running || 0),
|
||||
riskObservations: Number(item.riskObservations || 0),
|
||||
riskClues: Number(item.riskClues || 0),
|
||||
profileSnapshots: Number(item.profileSnapshots || 0),
|
||||
knowledgeDocuments: Number(item.knowledgeDocuments || 0),
|
||||
businessOutputs: Number(item.businessOutputs || 0)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeTaskRanking(dashboard) {
|
||||
return (dashboard?.taskDistribution || [])
|
||||
.slice(0, 6)
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
shortName: item.name,
|
||||
value: Number(item.value || item.count || 0),
|
||||
color: item.color || 'var(--theme-primary)'
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildDigitalEmployeeCategoryRows(dashboard) {
|
||||
return (dashboard?.categoryDistribution || [])
|
||||
.map((item) => ({
|
||||
...item,
|
||||
value: Number(item.value || item.count || 0),
|
||||
count: Number(item.count || item.value || 0)
|
||||
}))
|
||||
}
|
||||
124
web/src/views/scripts/receiptFolderDetailDashboard.js
Normal file
124
web/src/views/scripts/receiptFolderDetailDashboard.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export function createReceiptDetailDashboardModel({
|
||||
detailForm,
|
||||
editableOtherFields,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
selectedReceipt
|
||||
}) {
|
||||
const previewZoom = ref(1)
|
||||
const previewRotation = ref(0)
|
||||
const previewTransform = computed(() => `scale(${previewZoom.value}) rotate(${previewRotation.value}deg)`)
|
||||
const previewPageLabel = computed(() => {
|
||||
const pageCount = Number(selectedReceipt.value?.page_count || 1)
|
||||
return `1 / ${Number.isFinite(pageCount) && pageCount > 0 ? pageCount : 1}`
|
||||
})
|
||||
const ocrPreviewFields = computed(() => (
|
||||
editableOtherFields.value
|
||||
.filter((field) => String(field?.label || field?.key || field?.value || '').trim())
|
||||
.slice(0, 6)
|
||||
))
|
||||
const basicInfoItems = computed(() => [
|
||||
{ label: '票据类型', value: fallback(detailForm.document_type_label) },
|
||||
{ label: '票据名称', value: fallback(detailForm.file_name) },
|
||||
{ label: '提交人', value: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '当前用户') },
|
||||
{ label: '上传时间', value: formatDateTime(selectedReceipt.value?.uploaded_at) },
|
||||
{ label: '所属单据编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
|
||||
{ label: 'OCR 置信度', value: formatScore(selectedReceipt.value?.avg_score) }
|
||||
])
|
||||
const receiptStatusItems = computed(() => {
|
||||
const linked = selectedReceipt.value?.status === 'linked'
|
||||
return [
|
||||
{ label: '识别状态', value: '识别成功', tone: 'success' },
|
||||
{ label: '关联状态', value: selectedReceipt.value?.status_label || (linked ? '已关联' : '未关联'), tone: linked ? 'success' : 'warning' },
|
||||
{ label: '重复报销风险', value: '无风险', tone: 'success' },
|
||||
{ label: '归档状态', value: linked ? '待归档' : '未归档', tone: 'info' }
|
||||
]
|
||||
})
|
||||
const linkedClaimItems = computed(() => [
|
||||
{ label: '报销单编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
|
||||
{ label: '报销单名称', value: linkedClaimName.value },
|
||||
{ label: '费用类型', value: fallback(detailForm.scene_label) },
|
||||
{ label: '申请日期', value: dateOnly(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at) },
|
||||
{ label: '审批状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '待关联' },
|
||||
{ label: '是否已入账', value: '未入账' }
|
||||
])
|
||||
const operationLogs = computed(() => [
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.uploaded_at),
|
||||
operator: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '系统'),
|
||||
label: '上传票据'
|
||||
},
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.uploaded_at),
|
||||
operator: '系统',
|
||||
label: `OCR识别,提取 ${editableOtherFields.value.length} 项要素`
|
||||
},
|
||||
{
|
||||
time: formatDateTime(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at),
|
||||
operator: selectedReceipt.value?.status === 'linked' ? '系统' : '待处理',
|
||||
label: selectedReceipt.value?.status === 'linked' ? `关联单据 ${selectedReceipt.value?.linked_claim_no || ''}` : '等待关联单据'
|
||||
}
|
||||
])
|
||||
const archiveInfoItems = computed(() => [
|
||||
{ label: '归档编号', value: archiveNo.value },
|
||||
{ label: '归档目录', value: `${dateOnly(selectedReceipt.value?.uploaded_at)} / ${fallback(detailForm.scene_label)}` },
|
||||
{ label: '保管期限', value: '10年' },
|
||||
{ label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' },
|
||||
{ label: '文件格式', value: fileFormat.value },
|
||||
{ label: '文件大小', value: fallback(selectedReceipt.value?.file_size_label || selectedReceipt.value?.size_label, '待统计') }
|
||||
])
|
||||
const linkedClaimName = computed(() => (
|
||||
selectedReceipt.value?.linked_claim_no
|
||||
? `${fallback(detailForm.scene_label)}票据归集`
|
||||
: '暂未关联报销单'
|
||||
))
|
||||
const archiveNo = computed(() => (
|
||||
selectedReceipt.value?.id ? `DA-${String(selectedReceipt.value.id).slice(0, 8).toUpperCase()}` : '待生成'
|
||||
))
|
||||
const fileFormat = computed(() => {
|
||||
const fileName = String(detailForm.file_name || selectedReceipt.value?.file_name || '').trim()
|
||||
const suffix = fileName.includes('.') ? fileName.split('.').pop() : ''
|
||||
return suffix ? suffix.toUpperCase() : fallback(selectedReceipt.value?.preview_kind, '待识别')
|
||||
})
|
||||
|
||||
function adjustPreviewZoom(delta) {
|
||||
previewZoom.value = Math.min(1.8, Math.max(0.6, Number((previewZoom.value + delta).toFixed(2))))
|
||||
}
|
||||
|
||||
function resetPreviewView() {
|
||||
previewZoom.value = 1
|
||||
previewRotation.value = 0
|
||||
}
|
||||
|
||||
function rotatePreview() {
|
||||
previewRotation.value = (previewRotation.value + 90) % 360
|
||||
}
|
||||
|
||||
return {
|
||||
adjustPreviewZoom,
|
||||
archiveInfoItems,
|
||||
basicInfoItems,
|
||||
linkedClaimItems,
|
||||
ocrPreviewFields,
|
||||
operationLogs,
|
||||
previewPageLabel,
|
||||
previewRotation,
|
||||
previewTransform,
|
||||
previewZoom,
|
||||
receiptStatusItems,
|
||||
resetPreviewView,
|
||||
rotatePreview
|
||||
}
|
||||
}
|
||||
|
||||
function fallback(value, empty = '待补充') {
|
||||
const text = String(value || '').trim()
|
||||
return text || empty
|
||||
}
|
||||
|
||||
function dateOnly(value) {
|
||||
const text = String(value || '').trim()
|
||||
return text ? text.slice(0, 10) : '待确认'
|
||||
}
|
||||
@@ -503,6 +503,7 @@ export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
if (!hasUploadedType('hotel_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-hotel-ticket',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '住宿票据提醒',
|
||||
@@ -515,6 +516,7 @@ export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
if (!hasUploadedType('ride_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-ride-ticket',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '乘车票据提醒',
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
isRiskSummaryWithRisk,
|
||||
normalizeRiskFlagTone
|
||||
} from '../../utils/riskFlags.js'
|
||||
import {
|
||||
resolveRiskActionability,
|
||||
resolveRiskDomain,
|
||||
resolveRiskVisibilityScope
|
||||
} from '../../utils/riskVisibility.js'
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
@@ -28,6 +33,121 @@ function uniqueTexts(values) {
|
||||
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizeBusinessStage(value) {
|
||||
const stage = normalizeText(value).toLowerCase()
|
||||
if ([
|
||||
'expense_application',
|
||||
'application',
|
||||
'apply',
|
||||
'pre_apply',
|
||||
'pre_application',
|
||||
'budget_application'
|
||||
].includes(stage)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
if ([
|
||||
'reimbursement',
|
||||
'expense_reimbursement',
|
||||
'claim',
|
||||
'expense_claim',
|
||||
'expense_report'
|
||||
].includes(stage)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveFlagBusinessStage(flag, fallback = 'reimbursement') {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return resolveRiskTextBusinessStage(flag, fallback)
|
||||
}
|
||||
|
||||
const explicitStage = normalizeBusinessStage(
|
||||
flag.businessStage
|
||||
|| flag.business_stage
|
||||
|| flag.controlStage
|
||||
|| flag.control_stage
|
||||
)
|
||||
if (explicitStage) {
|
||||
return explicitStage
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source).toLowerCase()
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType).toLowerCase()
|
||||
if (source === 'attachment_analysis' || /expense_claim|reimbursement|payment/.test(eventType)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
if (/application/.test(source) || /expense_application/.test(eventType)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
|
||||
}
|
||||
|
||||
function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
|
||||
const text = normalizeText(value)
|
||||
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function cardLikeText(card = {}) {
|
||||
return [
|
||||
card.label,
|
||||
card.title,
|
||||
card.risk,
|
||||
card.message,
|
||||
card.summary,
|
||||
card.suggestion,
|
||||
card.description,
|
||||
card.detail
|
||||
].map((item) => normalizeText(item)).join(' ')
|
||||
}
|
||||
|
||||
function resolveRequestBusinessStage(request = {}) {
|
||||
const explicitStage = normalizeBusinessStage(
|
||||
request?.businessStage
|
||||
|| request?.business_stage
|
||||
|| request?.controlStage
|
||||
|| request?.control_stage
|
||||
)
|
||||
if (explicitStage) {
|
||||
return explicitStage
|
||||
}
|
||||
|
||||
const documentType = normalizeText(
|
||||
request?.documentTypeCode
|
||||
|| request?.document_type_code
|
||||
|| request?.documentType
|
||||
|| request?.document_type
|
||||
).toLowerCase()
|
||||
if (['application', 'expense_application'].includes(documentType)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
const claimNo = normalizeText(
|
||||
request?.claimNo
|
||||
|| request?.claim_no
|
||||
|| request?.documentNo
|
||||
|| request?.document_no
|
||||
|| request?.id
|
||||
).toUpperCase()
|
||||
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
const typeCode = normalizeText(request?.typeCode || request?.expense_type).toLowerCase()
|
||||
if (typeCode === 'application' || typeCode.endsWith('_application')) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
return 'reimbursement'
|
||||
}
|
||||
|
||||
function normalizeTone(value) {
|
||||
const tone = normalizeText(value).toLowerCase()
|
||||
if (tone === 'pass') return 'pass'
|
||||
@@ -37,6 +157,14 @@ function normalizeTone(value) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
function resolveRiskLevelLabel(tone) {
|
||||
const normalizedTone = normalizeTone(tone)
|
||||
if (normalizedTone === 'high') return '高风险'
|
||||
if (normalizedTone === 'medium') return '中风险'
|
||||
if (normalizedTone === 'low') return '低风险'
|
||||
return '风险提示'
|
||||
}
|
||||
|
||||
export function normalizeRiskTone(value) {
|
||||
return normalizeTone(value)
|
||||
}
|
||||
@@ -143,12 +271,34 @@ export function resolveRiskTags(card = {}) {
|
||||
}
|
||||
|
||||
function withRiskTags(card) {
|
||||
const businessStage = normalizeBusinessStage(
|
||||
card.businessStage
|
||||
|| card.business_stage
|
||||
|| card.controlStage
|
||||
|| card.control_stage
|
||||
)
|
||||
const riskDomain = resolveRiskDomain(card)
|
||||
const actionability = resolveRiskActionability(card, { businessStage, riskDomain })
|
||||
const visibilityScope = resolveRiskVisibilityScope(card, { businessStage, riskDomain, actionability })
|
||||
return {
|
||||
...card,
|
||||
...(businessStage ? { businessStage } : {}),
|
||||
riskDomain,
|
||||
risk_domain: riskDomain,
|
||||
actionability,
|
||||
visibilityScope,
|
||||
visibility_scope: visibilityScope,
|
||||
tags: resolveRiskTags(card)
|
||||
}
|
||||
}
|
||||
|
||||
export function filterRiskCardsByBusinessStage(cards = [], businessStage = 'reimbursement') {
|
||||
const targetStage = normalizeBusinessStage(businessStage) || 'reimbursement'
|
||||
return (Array.isArray(cards) ? cards : []).filter(
|
||||
(card) => resolveFlagBusinessStage(card, targetStage) === targetStage
|
||||
)
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
@@ -286,21 +436,24 @@ function buildCardSuggestion(analysis, insight) {
|
||||
)
|
||||
}
|
||||
|
||||
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
|
||||
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis, businessStage = 'reimbursement' }) {
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
|
||||
const title = normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name) || '附件风险'
|
||||
|
||||
return withRiskTags({
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
|
||||
tone,
|
||||
label,
|
||||
title: `第 ${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
|
||||
label: resolveRiskLevelLabel(tone),
|
||||
title: `第 ${index + 1} 条:${title}`,
|
||||
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
|
||||
summary: normalizeText(analysis?.summary),
|
||||
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
||||
suggestion: buildCardSuggestion(analysis, insight),
|
||||
itemType: normalizeText(item?.itemType),
|
||||
documentType: normalizeText(insight?.documentTypeLabel)
|
||||
documentType: normalizeText(insight?.documentTypeLabel),
|
||||
visibility_scope: 'submitter',
|
||||
actionability: 'fixable_by_submitter'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -334,7 +487,7 @@ function resolveLatestManualReturnFlag(flags) {
|
||||
}, manualReturnFlags[0])
|
||||
}
|
||||
|
||||
function buildManualReturnRiskCard(flag) {
|
||||
function buildManualReturnRiskCard(flag, businessStage = 'reimbursement') {
|
||||
if (!flag) {
|
||||
return null
|
||||
}
|
||||
@@ -355,21 +508,27 @@ function buildManualReturnRiskCard(flag) {
|
||||
|
||||
return withRiskTags({
|
||||
id: `manual-return-${returnCount || 'latest'}`,
|
||||
businessStage: resolveFlagBusinessStage(flag, normalizeBusinessStage(businessStage) || 'reimbursement'),
|
||||
tone: 'medium',
|
||||
label: '退回原因',
|
||||
title: returnCount ? `第 ${returnCount} 次退回` : '审批退回',
|
||||
risk,
|
||||
summary: normalizeText(flag.reason),
|
||||
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
|
||||
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
|
||||
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。',
|
||||
risk_domain: flag.risk_domain || flag.riskDomain || 'workflow',
|
||||
visibility_scope: flag.visibility_scope || flag.visibilityScope || 'submitter',
|
||||
actionability: flag.actionability || 'fixable_by_submitter'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAttachmentRiskCards({
|
||||
expenseItems = [],
|
||||
attachmentMetaByItemId = {},
|
||||
claimRiskFlags = []
|
||||
claimRiskFlags = [],
|
||||
businessStage = 'reimbursement'
|
||||
} = {}) {
|
||||
const normalizedBusinessStage = normalizeBusinessStage(businessStage) || 'reimbursement'
|
||||
const attachmentRiskItemIds = new Set()
|
||||
const attachmentCards = expenseItems.flatMap((item, index) => {
|
||||
if (!item?.invoiceId) {
|
||||
@@ -393,17 +552,31 @@ export function buildAttachmentRiskCards({
|
||||
: [analysis.summary || analysis.headline || analysis.label]
|
||||
|
||||
return points
|
||||
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
|
||||
.map((point, pointIndex) => buildRiskCardFromPoint({
|
||||
item,
|
||||
index,
|
||||
point,
|
||||
pointIndex,
|
||||
insight,
|
||||
analysis,
|
||||
businessStage: normalizedBusinessStage
|
||||
}))
|
||||
.filter((card) => card.risk)
|
||||
})
|
||||
|
||||
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
|
||||
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
|
||||
const latestManualReturnCard = buildManualReturnRiskCard(
|
||||
resolveLatestManualReturnFlag(normalizedClaimRiskFlags),
|
||||
normalizedBusinessStage
|
||||
)
|
||||
const claimCards = normalizedClaimRiskFlags
|
||||
.flatMap((flag, index) => {
|
||||
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
|
||||
return []
|
||||
}
|
||||
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
if (!isActionableRiskFlag(flag)) {
|
||||
@@ -414,6 +587,7 @@ export function buildAttachmentRiskCards({
|
||||
return risk
|
||||
? [withRiskTags({
|
||||
id: `claim-risk-${index}`,
|
||||
businessStage: resolveRiskTextBusinessStage(risk, normalizedBusinessStage),
|
||||
tone: 'medium',
|
||||
label: '单据风险',
|
||||
title: '单据风险提示',
|
||||
@@ -457,13 +631,17 @@ export function buildAttachmentRiskCards({
|
||||
|
||||
return risks.map((risk, pointIndex) => withRiskTags({
|
||||
id: `claim-risk-${index}-${pointIndex}`,
|
||||
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
|
||||
tone,
|
||||
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
|
||||
title: normalizeText(flag.label) || '单据风险提示',
|
||||
label: resolveRiskLevelLabel(tone),
|
||||
title: normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
|
||||
risk,
|
||||
summary,
|
||||
ruleBasis,
|
||||
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
|
||||
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
|
||||
risk_domain: flag.risk_domain || flag.riskDomain,
|
||||
visibility_scope: flag.visibility_scope || flag.visibilityScope,
|
||||
actionability: flag.actionability
|
||||
}))
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -504,11 +682,13 @@ export function buildClaimSummaryRiskCards(request = {}) {
|
||||
if (!isRiskTone(tone)) {
|
||||
return []
|
||||
}
|
||||
const businessStage = resolveRiskTextBusinessStage(summary, resolveRequestBusinessStage(request))
|
||||
|
||||
return [withRiskTags({
|
||||
id: 'claim-risk-summary',
|
||||
businessStage,
|
||||
tone,
|
||||
label: tone === 'high' ? '高风险' : '中风险',
|
||||
label: resolveRiskLevelLabel(tone),
|
||||
title: '单据风险提示',
|
||||
risk: summary,
|
||||
summary,
|
||||
@@ -524,6 +704,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedRiskCards = riskCards.filter(Boolean)
|
||||
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
|
||||
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
|
||||
|
||||
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
|
||||
const items = [
|
||||
@@ -553,8 +734,9 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
if (normalizedRiskCards.length) {
|
||||
sections.push({
|
||||
kind: 'risk',
|
||||
title: '已知存在风险',
|
||||
items: normalizedRiskCards
|
||||
title: `已知存在风险(${normalizedRiskCards.length}项)`,
|
||||
items: sortedRiskCards,
|
||||
totalCount: normalizedRiskCards.length
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,10 +744,25 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待核对',
|
||||
summary: normalizedRiskCards.length
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
|
||||
: '建议先补齐必填信息,完成后即可提交审批。',
|
||||
items: normalizedCompletionItems,
|
||||
riskCards: normalizedRiskCards,
|
||||
sections
|
||||
}
|
||||
}
|
||||
|
||||
function sortRiskCardsByTone(cards) {
|
||||
const toneWeight = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
normal: 3,
|
||||
pass: 4
|
||||
}
|
||||
return [...cards].sort((left, right) => {
|
||||
const leftWeight = toneWeight[normalizeText(left?.tone).toLowerCase()] ?? 9
|
||||
const rightWeight = toneWeight[normalizeText(right?.tone).toLowerCase()] ?? 9
|
||||
return leftWeight - rightWeight
|
||||
})
|
||||
}
|
||||
|
||||
74
web/src/views/scripts/travelRequestDetailPreReviewModel.js
Normal file
74
web/src/views/scripts/travelRequestDetailPreReviewModel.js
Normal file
@@ -0,0 +1,74 @@
|
||||
export function isAiPreReviewFlag(flag) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
const source = String(flag.source || '').trim()
|
||||
const eventType = String(flag.event_type || flag.eventType || '').trim()
|
||||
return source === 'ai_pre_review' || eventType === 'expense_claim_ai_pre_review'
|
||||
}
|
||||
|
||||
export function findLatestAiPreReviewEvent(flags = []) {
|
||||
return flags
|
||||
.filter(isAiPreReviewFlag)
|
||||
.map((flag) => ({
|
||||
...flag,
|
||||
eventTime: new Date(flag.created_at || flag.createdAt || 0).getTime()
|
||||
}))
|
||||
.sort((left, right) => (left.eventTime || 0) - (right.eventTime || 0))
|
||||
.pop() || null
|
||||
}
|
||||
|
||||
export function buildAiPreReviewSnapshot(payload, fallbackClaimId = '') {
|
||||
return {
|
||||
claimId: String(payload?.id || fallbackClaimId || '').trim(),
|
||||
riskFlags: Array.isArray(payload?.risk_flags_json) ? payload.risk_flags_json : []
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiPreReviewPassed(event, requiresAiPreReview) {
|
||||
if (!requiresAiPreReview) {
|
||||
return true
|
||||
}
|
||||
return Boolean(event?.passed) || String(event?.status || '').trim() === 'passed'
|
||||
}
|
||||
|
||||
export function resolveSubmitActionLabel({
|
||||
isApplicationDocument,
|
||||
hasAiPreReviewResult,
|
||||
submitBusy
|
||||
}) {
|
||||
if (isApplicationDocument) {
|
||||
return submitBusy ? '提交中' : '提交审批'
|
||||
}
|
||||
if (!hasAiPreReviewResult) {
|
||||
return submitBusy ? '审核中' : 'AI审核'
|
||||
}
|
||||
return submitBusy ? '提交中' : '下一步'
|
||||
}
|
||||
|
||||
export function resolveSubmitActionIcon({ isApplicationDocument, hasAiPreReviewResult }) {
|
||||
if (isApplicationDocument) {
|
||||
return 'mdi mdi-send-circle-outline'
|
||||
}
|
||||
return hasAiPreReviewResult ? 'mdi mdi-arrow-right-circle-outline' : 'mdi mdi-shield-check-outline'
|
||||
}
|
||||
|
||||
export function resolveSubmitConfirmDescription({ isApplicationDocument, aiPreReviewPassed }) {
|
||||
if (isApplicationDocument) {
|
||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||
}
|
||||
if (!aiPreReviewPassed) {
|
||||
return 'AI预审存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
|
||||
}
|
||||
return 'AI预审已完成,请确认费用明细、附件材料和风险说明均已核对无误。确认后将进入审批流程。'
|
||||
}
|
||||
|
||||
export function resolveSubmitConfirmText(isApplicationDocument) {
|
||||
return isApplicationDocument ? '确认提交' : '确认下一步'
|
||||
}
|
||||
|
||||
export function resolveAiPreReviewToast(event) {
|
||||
return event && (event.passed || event.status === 'passed')
|
||||
? 'AI预审通过,请点击下一步提交审批。'
|
||||
: 'AI预审发现重大风险,请核对 AI建议 后再点击下一步。'
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
buildApplicationPreviewRows,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
normalizeApplicationPreview,
|
||||
refreshApplicationPreviewTransportEstimate
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
@@ -33,10 +35,28 @@ function buildEmptyEditor() {
|
||||
dateMode: 'single',
|
||||
singleDate: getTodayDateValue(),
|
||||
rangeStartDate: getTodayDateValue(),
|
||||
rangeEndDate: getTodayDateValue()
|
||||
rangeEndDate: getTodayDateValue(),
|
||||
committing: false
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshTransportEstimate(fieldKey) {
|
||||
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
|
||||
}
|
||||
|
||||
function buildTransportEstimatePendingPreview(preview = {}) {
|
||||
const fields = preview?.fields || {}
|
||||
return normalizeApplicationPreview({
|
||||
...preview,
|
||||
fields: {
|
||||
...fields,
|
||||
transportPolicy: '正在查询交通参考票价...',
|
||||
policyEstimate: '正在同步费用测算...',
|
||||
transportEstimatedAmount: '查询中'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
|
||||
const applicationPreviewEditor = ref(buildEmptyEditor())
|
||||
|
||||
@@ -74,6 +94,7 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||
? ''
|
||||
: normalizedValue,
|
||||
committing: false,
|
||||
...dateState
|
||||
}
|
||||
}
|
||||
@@ -110,18 +131,29 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
})
|
||||
}
|
||||
|
||||
function commitApplicationPreviewEditor(message) {
|
||||
async function commitApplicationPreviewEditor(message) {
|
||||
const editor = applicationPreviewEditor.value
|
||||
if (editor.committing) {
|
||||
return false
|
||||
}
|
||||
if (!message?.applicationPreview || String(editor.messageId || '') !== String(message.id || '') || !editor.fieldKey) {
|
||||
cancelApplicationPreviewEditor()
|
||||
return false
|
||||
}
|
||||
applicationPreviewEditor.value = {
|
||||
...editor,
|
||||
committing: true
|
||||
}
|
||||
|
||||
const nextValue = editor.fieldKey === 'time'
|
||||
? buildApplicationPreviewDateDraftValue()
|
||||
: String(editor.draftValue || '').trim()
|
||||
if (editor.fieldKey === 'time' && !nextValue) {
|
||||
toast?.('请先选择有效日期。')
|
||||
applicationPreviewEditor.value = {
|
||||
...applicationPreviewEditor.value,
|
||||
committing: false
|
||||
}
|
||||
return false
|
||||
}
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
@@ -131,15 +163,31 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
[editor.fieldKey]: nextValue
|
||||
}
|
||||
})
|
||||
message.applicationPreview = nextPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(nextPreview)
|
||||
const needRefreshTransport = shouldRefreshTransportEstimate(editor.fieldKey) && String(nextPreview.fields?.transportMode || '').trim()
|
||||
message.applicationPreview = needRefreshTransport
|
||||
? buildTransportEstimatePendingPreview(nextPreview)
|
||||
: nextPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(message.applicationPreview)
|
||||
cancelApplicationPreviewEditor()
|
||||
persistSessionState?.()
|
||||
if (needRefreshTransport) {
|
||||
await waitForMockApplicationTransportQuote({
|
||||
transportMode: nextPreview.fields.transportMode,
|
||||
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
|
||||
time: nextPreview.fields.time
|
||||
})
|
||||
const refreshedPreview = refreshApplicationPreviewTransportEstimate(nextPreview)
|
||||
message.applicationPreview = refreshedPreview
|
||||
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
|
||||
persistSessionState?.()
|
||||
toast?.('已更新出行方式和费用测算。')
|
||||
return true
|
||||
}
|
||||
toast?.('已更新核对表内容。')
|
||||
return true
|
||||
}
|
||||
|
||||
function commitApplicationPreviewDateEditor(message) {
|
||||
async function commitApplicationPreviewDateEditor(message) {
|
||||
if (!canApplyApplicationPreviewDateSelection()) {
|
||||
toast?.('请确认结束日期不早于开始日期。')
|
||||
return false
|
||||
|
||||
@@ -112,9 +112,12 @@ export function useAuditAssetData({
|
||||
await loadAssets({ force: true, silent: true, background: true })
|
||||
}
|
||||
|
||||
async function loadSelectedAssetDetail(assetId) {
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
async function loadSelectedAssetDetail(assetId, options = {}) {
|
||||
const silent = Boolean(options.silent)
|
||||
if (!silent) {
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
if (!runs.value.length) {
|
||||
@@ -155,10 +158,17 @@ export function useAuditAssetData({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
detailError.value = error?.message || '资产详情加载失败,请稍后重试。'
|
||||
toast(detailError.value)
|
||||
const message = error?.message || '资产详情加载失败,请稍后重试。'
|
||||
if (silent) {
|
||||
console.warn('Silent asset detail refresh failed:', error)
|
||||
} else {
|
||||
detailError.value = message
|
||||
toast(message)
|
||||
}
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
if (!silent) {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
deleteAgentAsset,
|
||||
fetchAgentAssetDetail,
|
||||
publishRiskRuleAsset,
|
||||
regenerateRiskRuleAsset,
|
||||
returnRiskRuleAsset,
|
||||
setRiskRuleAssetEnabled,
|
||||
updateRiskRuleDraft
|
||||
} from '../../services/agentAssets.js'
|
||||
import { normalizeText } from './auditViewModel.js'
|
||||
|
||||
const DEFAULT_EXPENSE_CATEGORY = 'travel'
|
||||
const DEFAULT_EXPENSE_CATEGORY = 'all'
|
||||
|
||||
export function useAuditRiskRuleActions({
|
||||
selectedSkill,
|
||||
@@ -127,16 +128,22 @@ export function useAuditRiskRuleActions({
|
||||
}
|
||||
|
||||
actionState.value = 'save-risk-rule-edit'
|
||||
const assetId = selectedSkill.value.id
|
||||
try {
|
||||
const detail = isRevision
|
||||
? await createRiskRuleRevision(selectedSkill.value.id, payload, { actor: resolveActor() })
|
||||
: await updateRiskRuleDraft(selectedSkill.value.id, payload, { actor: resolveActor() })
|
||||
const actor = resolveActor()
|
||||
if (isRevision) {
|
||||
await createRiskRuleRevision(assetId, payload, { actor })
|
||||
} else {
|
||||
await updateRiskRuleDraft(assetId, payload, { actor })
|
||||
}
|
||||
const regenerated = await regenerateRiskRuleAsset(assetId, buildRegeneratePayload(payload), { actor })
|
||||
riskRuleEditOpen.value = false
|
||||
mergeSelectedRuleLifecycle(detail)
|
||||
await refreshCurrentAssets()
|
||||
toast(isRevision ? '已创建风险规则修订草稿。' : '风险规则草稿已更新。')
|
||||
mergeSelectedRuleLifecycle(regenerated)
|
||||
await loadSelectedAssetDetail(assetId, { silent: true })
|
||||
toast(isRevision ? '已创建修订草稿并重新生成规则。' : '风险规则草稿已保存并重新生成。')
|
||||
} catch (error) {
|
||||
toast(error?.message || (isRevision ? '创建修订版本失败,请稍后重试。' : '编辑规则草稿失败,请稍后重试。'))
|
||||
toast(error?.message || (isRevision ? '创建并生成修订版本失败,请稍后重试。' : '保存并生成规则草稿失败,请稍后重试。'))
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
@@ -207,7 +214,7 @@ export function useAuditRiskRuleActions({
|
||||
await returnRiskRuleAsset(selectedSkill.value.id, { note }, { actor: resolveActor() })
|
||||
riskRuleReturnOpen.value = false
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||
toast('风险规则已回退到草稿。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '风险规则回退失败,请稍后重试。')
|
||||
@@ -243,7 +250,7 @@ export function useAuditRiskRuleActions({
|
||||
await publishRiskRuleAsset(selectedSkill.value.id, { actor: resolveActor() })
|
||||
riskRulePublishOpen.value = false
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||
toast('风险规则已发布上线。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '风险规则发布失败,请稍后重试。')
|
||||
@@ -328,3 +335,12 @@ function normalizeRiskRuleEditPayload(form, includeReason) {
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function buildRegeneratePayload(payload) {
|
||||
return {
|
||||
rule_title: payload.rule_title,
|
||||
expense_category: payload.expense_category,
|
||||
requires_attachment: payload.requires_attachment,
|
||||
natural_language: payload.natural_language
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useAuditRiskRuleCreateFlow({
|
||||
try {
|
||||
const detail = await generateRiskRuleAsset(
|
||||
{
|
||||
business_domain: 'expense',
|
||||
business_domain: riskRuleCreateForm.value.business_domain || 'expense',
|
||||
business_stage: riskRuleCreateForm.value.business_stage,
|
||||
expense_category: riskRuleCreateForm.value.expense_category,
|
||||
rule_title: ruleTitle,
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useAuditRuleReviewFlow({
|
||||
{ actor: resolveActor() }
|
||||
)
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||
toast(`当前规则版本已标记为${resolveReviewMeta(reviewStatus).label}。`)
|
||||
} catch (error) {
|
||||
toast(error?.message || '规则审核提交失败,请稍后重试。')
|
||||
@@ -161,7 +161,7 @@ export function useAuditRuleReviewFlow({
|
||||
)
|
||||
reviewSubmitOpen.value = false
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||
toast(`规则版本 ${version} 已提交给 ${reviewer} 审核。`)
|
||||
} catch (error) {
|
||||
toast(error?.message || '规则审核提交失败,请稍后重试。')
|
||||
|
||||
@@ -75,7 +75,7 @@ export function useAuditRuleVersionActions({
|
||||
)
|
||||
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||
toast(`${successLabel} ${nextVersion}。`)
|
||||
} catch (error) {
|
||||
toast(error?.message || `${successLabel}失败,请稍后重试。`)
|
||||
@@ -109,7 +109,7 @@ export function useAuditRuleVersionActions({
|
||||
try {
|
||||
await activateAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||
toast('规则已正式上线。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '规则上线失败,请稍后重试。')
|
||||
@@ -133,7 +133,7 @@ export function useAuditRuleVersionActions({
|
||||
try {
|
||||
await restoreAgentAssetVersion(selectedSkill.value.id, version, { actor: resolveActor() })
|
||||
await refreshCurrentAssets()
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
||||
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
|
||||
toast(`已基于 ${version} 生成新的工作版本。`)
|
||||
} catch (error) {
|
||||
toast(error?.message || '历史版本恢复失败,请稍后重试。')
|
||||
|
||||
@@ -101,6 +101,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer,
|
||||
currentUser,
|
||||
refreshCurrentUserFromBackend,
|
||||
toast
|
||||
}) {
|
||||
const guidedPendingFiles = ref([])
|
||||
@@ -151,9 +152,19 @@ export function useTravelReimbursementGuidedFlow({
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function startGuidedApplicationTemplate() {
|
||||
async function resolveApplicationPreviewUser() {
|
||||
const user = currentUser?.value || {}
|
||||
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
|
||||
return user
|
||||
}
|
||||
|
||||
await refreshCurrentUserFromBackend({ silent: true })
|
||||
return currentUser?.value || user
|
||||
}
|
||||
|
||||
async function startGuidedApplicationTemplate() {
|
||||
resetGuidedFlowState()
|
||||
const applicationPreview = buildApplicationTemplatePreview(currentUser?.value || {})
|
||||
const applicationPreview = buildApplicationTemplatePreview(await resolveApplicationPreviewUser())
|
||||
pushAssistant(buildLocalApplicationPreviewMessage(applicationPreview), {
|
||||
meta: ['申请模板'],
|
||||
applicationPreview
|
||||
@@ -171,10 +182,10 @@ export function useTravelReimbursementGuidedFlow({
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function handleGuidedShortcut(shortcut) {
|
||||
async function handleGuidedShortcut(shortcut) {
|
||||
const actionType = normalizeText(shortcut?.action)
|
||||
if (actionType === GUIDED_ACTION_START_APPLICATION) {
|
||||
startGuidedApplicationTemplate()
|
||||
await startGuidedApplicationTemplate()
|
||||
return true
|
||||
}
|
||||
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
|
||||
@@ -245,6 +256,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
claimsPayload = await fetchExpenseClaims()
|
||||
} catch (error) {
|
||||
console.warn('Fetch reimbursement applications failed:', error)
|
||||
guidedFlowState.value = createEmptyGuidedFlowState()
|
||||
pushAssistant('查询可关联申请单时出现异常,请稍后再试。为避免直接报销,我先暂停当前流程。', {
|
||||
meta: ['申请单查询失败']
|
||||
})
|
||||
@@ -254,10 +266,9 @@ export function useTravelReimbursementGuidedFlow({
|
||||
|
||||
const applications = filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser?.value || {})
|
||||
if (!applications.length) {
|
||||
guidedFlowState.value = createGuidedReimbursementState()
|
||||
guidedFlowState.value = createEmptyGuidedFlowState()
|
||||
pushAssistant(buildRequiredApplicationMissingText(expenseType), {
|
||||
meta: ['缺少可关联申请单'],
|
||||
suggestedActions: buildGuidedExpenseTypeActions()
|
||||
meta: ['缺少可关联申请单']
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
buildModelRefinedApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
@@ -79,6 +80,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
persistSessionState,
|
||||
props,
|
||||
recognizeOcrFiles,
|
||||
refreshCurrentUserFromBackend,
|
||||
refreshFlowRunDetail,
|
||||
rememberFilePreviews,
|
||||
replaceMessage,
|
||||
@@ -339,8 +341,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
]
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText) {
|
||||
async function resolveApplicationPreviewUser() {
|
||||
const user = currentUser.value || {}
|
||||
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
|
||||
return user
|
||||
}
|
||||
|
||||
await refreshCurrentUserFromBackend({ silent: true })
|
||||
return currentUser.value || user
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText) {
|
||||
const user = await resolveApplicationPreviewUser()
|
||||
const localPreview = buildLocalApplicationPreview(rawText, user)
|
||||
|
||||
const enrichWithPolicyEstimate = async (preview) => {
|
||||
@@ -349,6 +361,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return preview
|
||||
}
|
||||
try {
|
||||
const fields = preview?.fields || {}
|
||||
await waitForMockApplicationTransportQuote({
|
||||
transportMode: fields.transportMode,
|
||||
location: fields.location,
|
||||
time: fields.time
|
||||
})
|
||||
const result = await calculateTravelReimbursement(estimateRequest.payload)
|
||||
return applyApplicationPolicyEstimateResult(preview, result, user)
|
||||
} catch (error) {
|
||||
@@ -548,14 +566,14 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
startFlowStep('application-review-preview', {
|
||||
title: '申请信息核对',
|
||||
tool: 'ontology.application_review',
|
||||
detail: '正在进行申请信息模型复核...'
|
||||
detail: '正在复核申请信息,并查询交通票价...'
|
||||
})
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
'正在进行申请信息模型复核。本步骤只识别意图和抽取字段,不会创建、更新或保存草稿。',
|
||||
'正在复核申请信息,并查询交通票价,请稍候。',
|
||||
[],
|
||||
{
|
||||
meta: ['模型复核中']
|
||||
@@ -770,7 +788,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
isKnowledgeSession.value
|
||||
? '正在整理财务知识答案...'
|
||||
: activeSessionType.value === 'application'
|
||||
? '正在识别并整理申请核对信息...'
|
||||
? '正在识别申请信息并查询交通票价...'
|
||||
: activeSessionType.value === 'approval'
|
||||
? '正在查询审核上下文并整理风险提示...'
|
||||
: '正在识别并整理右侧核对信息...'
|
||||
@@ -1037,20 +1055,29 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
nextTick(scrollToBottom)
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
.then((syncResult) => {
|
||||
const persistComposerFilesToDraft = async () => {
|
||||
try {
|
||||
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
persistSessionState()
|
||||
if (detailScopedUpload && Number(syncResult?.uploadedCount || 0) > 0) {
|
||||
if (detailScopedUpload) {
|
||||
emitRequestUpdated?.({
|
||||
claimId: resolvedDraftClaimId,
|
||||
source: 'detail-smart-entry-attachment-sync'
|
||||
source: 'detail-smart-entry-attachment-sync',
|
||||
uploadedCount: Number(syncResult?.uploadedCount || 0),
|
||||
skippedCount: Number(syncResult?.skippedCount || 0)
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
||||
})
|
||||
}
|
||||
}
|
||||
const persistTask = persistComposerFilesToDraft()
|
||||
if (detailScopedUpload) {
|
||||
await persistTask
|
||||
} else {
|
||||
void persistTask
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
|
||||
Reference in New Issue
Block a user