feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -123,7 +123,6 @@
z-index: 1;
}
.todo-row,
.progress-row {
position: relative;
border-top: 0;
@@ -131,12 +130,10 @@
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
}
.todo-row:first-child,
.progress-row:first-child {
box-shadow: none;
}
.todo-row:hover,
.progress-row:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),

View File

@@ -63,7 +63,7 @@
}
.assistant-copy {
width: min(1040px, 92%);
width: min(940px, 92%);
}
.assistant-copy h1 {
@@ -71,11 +71,11 @@
}
.capability-grid--privileged {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.capability-grid--standard {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.capability-card {
@@ -87,7 +87,7 @@
}
.workbench-content-grid {
grid-template-columns: minmax(300px, 0.92fr) minmax(480px, 1.34fr) minmax(270px, 0.76fr);
grid-template-columns: minmax(480px, 1.34fr) minmax(270px, 0.76fr);
gap: 14px;
}
@@ -202,25 +202,15 @@
grid-template-columns: 1fr;
}
.todo-row,
.progress-row {
grid-template-columns: 1fr;
justify-items: start;
}
.todo-row {
grid-template-columns: 48px minmax(0, 1fr);
}
.todo-meta,
.progress-result {
justify-items: start;
}
.todo-meta {
grid-column: 2;
}
.progress-steps {
width: 100%;
}
@@ -332,30 +322,6 @@
font-size: 11px;
}
/* 我的待办列表项更精致 */
.todo-row {
padding: 5px 0;
gap: 6px;
}
.todo-copy strong {
font-size: 12.5px;
}
.todo-copy small {
font-size: 11px;
}
.todo-status {
font-size: 11px;
min-height: 18px;
padding: 0 5px;
}
.todo-meta small {
font-size: 10.5px;
}
/* 重点优化费用进度行的网格区域Grid Area双行重构 */
.progress-row {
display: grid;

View File

@@ -97,7 +97,7 @@
.assistant-copy {
position: relative;
z-index: 3;
width: min(1120px, 94%);
width: min(980px, 94%);
display: grid;
gap: var(--hero-copy-gap);
}
@@ -130,6 +130,7 @@
z-index: 5;
display: grid;
gap: 6px;
max-width: 920px;
min-height: var(--composer-min-height);
padding: var(--composer-padding-block) 18px 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
@@ -416,7 +417,7 @@
.workbench-content-grid {
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(560px, 1.4fr) minmax(320px, 0.82fr);
grid-template-columns: minmax(560px, 1.45fr) minmax(320px, 0.82fr);
gap: 14px;
align-items: stretch;
min-height: 0;
@@ -434,7 +435,6 @@
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
}
.todo-panel,
.progress-panel,
.side-panel {
display: grid;
@@ -493,7 +493,6 @@
color: var(--workbench-muted);
}
.todo-list,
.progress-list {
display: grid;
min-height: 0;
@@ -501,45 +500,11 @@
grid-auto-rows: minmax(0, 1fr);
}
.todo-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
width: 100%;
padding: 2px 0;
border-top: 1px solid var(--workbench-line-soft);
text-align: left;
}
.todo-row:first-child,
.progress-row:first-child {
padding-top: 2px;
border-top: 0;
}
.todo-row :deep(.workbench-list-icon) {
width: 28px;
height: 28px;
}
.todo-row :deep(.workbench-list-icon__panel) {
border-radius: 4px;
}
.todo-row :deep(.workbench-list-icon__art),
.todo-row :deep(.workbench-heroicon) {
width: 16px;
height: 16px;
}
.todo-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.todo-copy strong,
.progress-identity strong,
.progress-result strong {
overflow: hidden;
@@ -551,8 +516,6 @@
white-space: nowrap;
}
.todo-copy small,
.todo-meta small,
.progress-identity small {
overflow: hidden;
color: var(--workbench-muted);
@@ -562,14 +525,6 @@
white-space: nowrap;
}
.todo-meta {
min-width: 96px;
display: grid;
justify-items: end;
gap: 2px;
}
.todo-status,
.progress-status {
display: inline-flex;
align-items: center;
@@ -582,33 +537,16 @@
white-space: nowrap;
}
.todo-status--warning,
.progress-status--warning {
background: var(--warning-soft);
color: var(--warning);
}
.todo-status--success,
.progress-status--success {
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
}
.todo-status--danger {
background: var(--danger-soft);
color: var(--danger);
}
.todo-status--info {
background: var(--info-soft);
color: var(--info);
}
.todo-status--orange {
background: var(--warning-soft);
color: var(--warning);
}
.progress-status--muted {
background: var(--info-soft);
color: var(--workbench-muted);
@@ -708,7 +646,6 @@
}
.capability-card:hover,
.todo-row:hover,
.progress-row:hover,
.quick-prompts button:hover,
.composer-icon-button:hover {

View File

@@ -403,6 +403,11 @@
color: var(--theme-primary-active);
}
.notification-wrap {
position: relative;
display: inline-flex;
}
.notification-badge {
position: absolute;
top: 2px;
@@ -423,6 +428,179 @@
box-shadow: 0 5px 10px rgba(239, 68, 68, .22);
}
.notification-popover {
position: absolute;
top: calc(100% + 10px);
right: -8px;
z-index: 60;
width: min(360px, calc(100vw - 32px));
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #e5edf5;
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
}
.notification-popover::before {
content: "";
position: absolute;
top: -6px;
right: 18px;
width: 10px;
height: 10px;
border-top: 1px solid #e5edf5;
border-left: 1px solid #e5edf5;
background: #fff;
transform: rotate(45deg);
}
.notification-head,
.notification-tabs {
position: relative;
z-index: 1;
display: flex;
align-items: center;
}
.notification-head {
justify-content: space-between;
gap: 10px;
}
.notification-head strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.notification-head button {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
color: #64748b;
}
.notification-head button:hover {
background: #f1f5f9;
color: #0f172a;
}
.notification-tabs {
gap: 6px;
padding: 3px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.notification-tabs button {
flex: 1 1 0;
height: 28px;
border-radius: 3px;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.notification-tabs button.active {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.notification-list {
position: relative;
z-index: 1;
display: grid;
max-height: 320px;
overflow: auto;
}
.notification-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) 16px;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-top: 1px solid #edf2f7;
text-align: left;
}
.notification-row:first-child {
border-top: 0;
}
.notification-row:hover {
background: #f8fafc;
}
.notification-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--theme-primary);
}
.notification-dot.danger { background: #ef4444; }
.notification-dot.warning { background: #f59e0b; }
.notification-dot.success { background: var(--success); }
.notification-dot.info { background: #3b82f6; }
.notification-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.notification-copy strong,
.notification-copy small,
.notification-copy em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-copy strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.notification-copy small {
color: #475569;
font-size: 12px;
}
.notification-copy em {
color: #94a3b8;
font-size: 11px;
font-style: normal;
}
.notification-row > .mdi {
color: #94a3b8;
font-size: 16px;
}
.notification-empty {
min-height: 112px;
display: grid;
place-items: center;
gap: 8px;
color: #94a3b8;
font-size: 13px;
}
.notification-empty .mdi {
font-size: 24px;
}
.company-switcher {
max-width: min(220px, 28vw);
height: 38px;
@@ -593,6 +771,61 @@
.title-group {
padding-right: 56px;
}
.topbar.detail-mode {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 10px 14px;
}
.topbar.detail-mode .title-group {
min-width: 0;
padding-right: 50px;
}
.topbar.detail-mode .eyebrow {
display: none;
}
.topbar.detail-mode h1 {
font-size: 22px;
line-height: 1.12;
}
.topbar.detail-mode p {
max-width: 100%;
margin-top: 3px;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-size: 12px;
line-height: 1.35;
}
.topbar.detail-mode .top-actions,
.topbar.detail-mode .detail-topbar-actions {
width: 100%;
min-width: 0;
justify-content: flex-end;
}
.topbar.detail-mode .detail-alert-strip {
width: auto;
max-width: 100%;
min-width: 0;
justify-content: flex-end;
gap: 6px;
}
.topbar.detail-mode .detail-alert-pill {
min-height: 26px;
max-width: 100%;
padding: 0 9px;
border-radius: 4px;
font-size: 11px;
}
}
@media (max-width: 640px) {
@@ -676,3 +909,22 @@
grid-template-columns: 1fr;
}
}
@media (max-width: 420px) {
.topbar.detail-mode {
gap: 6px;
padding: 8px 12px;
}
.topbar.detail-mode h1 {
font-size: 20px;
}
.topbar.detail-mode p {
font-size: 11.5px;
}
.topbar.detail-mode .detail-alert-pill {
min-height: 24px;
}
}

View File

@@ -142,19 +142,16 @@
.trend-count-panel,
.donut-panel,
.rank-panel,
.employee-rank-panel,
.top-claim-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel,
.model-panel,
.feedback-panel {
grid-column: span 3;
}
.bottleneck-panel,
.rank-panel,
.employee-rank-panel,
.top-claim-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 6;
}
@@ -188,6 +185,21 @@
width: 110px;
}
.card-range-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), .18);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), .07);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.panel-note {
margin-top: 8px;
color: #64748b;
@@ -581,6 +593,42 @@
.top-claim-list {
display: grid;
gap: 10px;
align-content: start;
}
.top-claim-split {
flex: 1;
display: grid;
grid-template-columns: minmax(260px, .92fr) minmax(0, 1.08fr);
gap: 18px;
align-items: stretch;
min-height: 0;
}
.department-employee-mix {
min-width: 0;
padding-right: 18px;
border-right: 1px solid #f1f5f9;
}
.department-employee-mix :deep(.donut-chart) {
min-height: 100%;
}
.department-employee-mix :deep(.donut-body) {
height: 150px;
}
.department-employee-mix :deep(.donut-legend) {
grid-template-columns: 1fr;
gap: 7px;
}
.department-employee-mix :deep(.legend-name) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-claim-row {
@@ -873,6 +921,17 @@
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.top-claim-split {
grid-template-columns: 1fr;
}
.department-employee-mix {
padding-right: 0;
padding-bottom: 14px;
border-right: 0;
border-bottom: 1px solid #f1f5f9;
}
.budget-metric-grid {
grid-template-columns: 1fr;
}

View File

@@ -0,0 +1,69 @@
@media (max-width: 760px) {
.approval-page,
.approval-detail,
.detail-scroll,
.detail-hero,
.progress-card,
.detail-grid,
.detail-left,
.detail-card {
width: 100%;
min-width: 0;
max-width: 100%;
}
.detail-scroll {
padding-right: 0;
overflow-x: hidden;
}
.detail-scroll > * {
min-width: 0;
max-width: 100%;
}
.hero-banner-main,
.hero-fact-grid,
.applicant-card,
.detail-card-head {
min-width: 0;
max-width: 100%;
}
.progress-card,
.progress-block {
min-width: 0;
max-width: 100%;
}
.progress-line {
width: 100%;
min-width: 0;
max-width: 100%;
}
.detail-expense-table {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.detail-actions {
width: 100%;
min-width: 0;
max-width: 100%;
}
.approval-action-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
gap: 8px;
}
.back-action,
.approval-action-group > button {
width: 100%;
min-width: 0;
}
}

View File

@@ -704,6 +704,7 @@
.ai-preview-secondary:disabled,
.ai-preview-primary:disabled,
.approve-action:disabled,
.secondary-action:disabled,
.return-action:disabled,
.ai-send-btn:disabled {
opacity: .45;

View File

@@ -1645,6 +1645,13 @@
color: #ef4444;
}
.secondary-action {
min-width: 98px;
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.return-action {
min-width: 98px;
border: 1px solid #fed7aa;

View File

@@ -16,7 +16,6 @@
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleSkills.length"
@@ -326,14 +325,6 @@ const pagedSkills = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return props.visibleSkills.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
)

View File

@@ -10,7 +10,6 @@
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pageNumbers"
:show-page-size="true"
:summary="paginationSummary"
:total="visibleEmployees.length"
@@ -225,14 +224,6 @@ const pagedEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return props.visibleEmployees.slice(start, start + pageSize.value)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)

View File

@@ -110,6 +110,26 @@
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察</p>
</section>
<section v-else-if="productKind === 'finance_snapshot'" class="run-product-section">
<div class="run-product-section-head">
<h4>财务经营快照</h4>
<span>{{ summary.period || summary.month || '本期' }}</span>
</div>
<p class="run-product-copy">
本次产物已刷新财务看板缓存沉淀报销金额预算使用费用结构和高额单据等经营指标
</p>
</section>
<section v-else-if="productKind === 'reminder_scan'" class="run-product-section">
<div class="run-product-section-head">
<h4>提醒与待办沉淀</h4>
<span>{{ summary.reminder_count || summary.reminders || 0 }} </span>
</div>
<p class="run-product-copy">
本次产物已生成审批提醒预算编制提醒报销逾期提醒和差旅申请闭环提醒
</p>
</section>
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
<div class="run-product-section-head">
<h4>待复核线索</h4>
@@ -230,6 +250,12 @@ const productSubtitle = computed(() => {
if (productKind.value === 'risk_graph') {
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
}
if (productKind.value === 'finance_snapshot') {
return '展示本次财务经营快照沉淀的预算、费用和报销统计。'
}
if (productKind.value === 'reminder_scan') {
return '展示本次定时提醒扫描生成的待办和触达结果。'
}
if (productKind.value === 'employee_profile') {
return '展示本次画像巡检写入的员工画像快照摘要。'
}
@@ -245,6 +271,12 @@ const productBadge = computed(() => {
if (productKind.value === 'risk_graph') {
return '风险观察'
}
if (productKind.value === 'finance_snapshot') {
return '财务快照'
}
if (productKind.value === 'reminder_scan') {
return '提醒事项'
}
if (productKind.value === 'employee_profile') {
return '画像快照'
}
@@ -281,6 +313,25 @@ const metrics = computed(() => {
buildMetric('图谱关系', payload.graph_edge_count)
]
}
if (productKind.value === 'finance_snapshot') {
return [
buildMetric('报销单数', payload.claim_count ?? payload.claims ?? payload.total_claims),
buildMetric(
'报销金额',
formatMoney(payload.claim_amount ?? payload.reimbursement_amount ?? payload.total_amount)
),
buildMetric('预算使用率', formatPercent(payload.budget_usage_rate ?? payload.budget_rate)),
buildMetric('高额单据', payload.high_value_claim_count ?? payload.high_amount_claims)
]
}
if (productKind.value === 'reminder_scan') {
return [
buildMetric('提醒人数', payload.recipient_count),
buildMetric('提醒事项', payload.reminder_count),
buildMetric('待审批', payload.approval_pending_count),
buildMetric('逾期报销', payload.reimbursement_overdue_count)
]
}
if (productKind.value === 'employee_profile') {
return [
buildMetric('目标员工', payload.target_employee_count),
@@ -376,6 +427,23 @@ function formatWindowDays(value) {
return days.length ? days.map((item) => `${item}`).join(' / ') : '-'
}
function formatMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return '-'
}
return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`
}
function formatPercent(value) {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) {
return '-'
}
const percent = numericValue > 1 ? numericValue : numericValue * 100
return `${Math.round(percent)}%`
}
function observationGraphCount(item) {
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
}

View File

@@ -14,7 +14,6 @@
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:pages="pageNumbers"
:show-page-size="false"
:summary="paginationSummary"
:total="filteredRuns.length"
@@ -303,6 +302,7 @@ import {
import {
formatWorkRecordDateTime,
formatWorkRecordSummary,
compactDigitalEmployeeWorkRecords,
resolveWorkRecordModuleLabel,
resolveWorkRecordSourceLabel,
resolveWorkRecordStatusLabel,
@@ -456,14 +456,6 @@ const visibleRuns = computed(() => {
return filteredRuns.value.slice(start, start + pageSize)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
@@ -523,7 +515,7 @@ async function loadWorkRecords(showToast = false) {
try {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
runs.value = Array.isArray(payload) ? payload : []
runs.value = Array.isArray(payload) ? compactDigitalEmployeeWorkRecords(payload) : []
emit('summary-change', {
total: workRecordSummary.value.total,
succeeded: workRecordSummary.value.succeeded,

View File

@@ -191,41 +191,6 @@
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card todo-panel">
<div class="section-head">
<div class="title-with-badge">
<h2>我的待办</h2>
<span class="soft-badge">{{ todoAlertCount }}</span>
</div>
<button type="button" class="link-action">全部待办 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="todo-list">
<button
v-for="item in visibleTodoItems"
:key="item.title"
type="button"
class="todo-row"
@click="openPromptAssistant(`帮我处理:${item.title}${item.description}`)"
>
<WorkbenchListIcon
:icon-key="item.iconKey"
:color="item.color"
:accent="item.accent"
/>
<span class="todo-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
</span>
<span class="todo-meta">
<span class="todo-status" :class="`todo-status--${item.statusTone}`">{{ item.status }}</span>
<small>{{ item.due }}</small>
</span>
</button>
</div>
</article>
<article class="panel workbench-card progress-panel">
<div class="section-head">
<h2>费用进度</h2>
@@ -238,7 +203,7 @@
:key="item.id"
type="button"
class="progress-row"
@click="openPromptAssistant(`查询 ${item.id} 的费用进度`)"
@click="openWorkbenchTarget(item)"
>
<span class="progress-identity">
<strong>{{ item.id }}</strong>
@@ -247,17 +212,17 @@
<span class="progress-steps" aria-hidden="true">
<span
v-for="(step, index) in progressSteps"
:key="step"
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': index < item.activeStep,
'is-current': index === item.activeStep,
'is-future': index > item.activeStep
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="index <= item.activeStep ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step }}</small>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
@@ -357,7 +322,6 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -365,11 +329,8 @@ import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposer
import {
buildExpenseStatItems,
filterAssistantCapabilitiesForUser,
progressItems,
progressSteps,
quickPromptItems,
resolveWorkbenchCapabilityGridClass,
todoItems,
} from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
@@ -394,7 +355,7 @@ const props = defineProps({
workbenchSummary: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-assistant'])
const emit = defineEmits(['open-assistant', 'open-document'])
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
@@ -494,9 +455,12 @@ const currentUserProfileKey = computed(() => {
user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
const todoAlertCount = computed(() => visibleTodoItems.value.length)
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems
: []
return rows.slice(0, 5)
})
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
@@ -625,6 +589,20 @@ function openPromptAssistant(prompt) {
emitAssistant(payload)
}
function openWorkbenchTarget(item) {
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('open-document', {
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
return
}
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`)
}
function openCapabilityAssistant(item) {
if (pendingAction.value) {
return

View File

@@ -9,7 +9,10 @@
</svg>
<template v-else>{{ idx + 1 }}</template>
</span>
<span class="rank-name">{{ item.name || item.shortName }}</span>
<span class="rank-copy">
<span class="rank-name">{{ item.name || item.shortName }}</span>
<small v-if="item.meta" class="rank-meta">{{ item.meta }}</small>
</span>
</div>
</div>
<div ref="chartElement" class="chart-area" role="img" :aria-label="ariaLabel"></div>
@@ -90,7 +93,11 @@ const chartOptions = computed(() => ({
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => `${params.marker}${params.name}: ${formatValue(params.value)}`
formatter: (params) => {
const item = resolvedItems.value.find((row) => (row.name || row.shortName) === params.name)
const meta = item?.meta ? `<br/>${item.meta}` : ''
return `${params.marker}${params.name}: ${formatValue(params.value)}${meta}`
}
},
xAxis: {
type: 'value',
@@ -180,7 +187,8 @@ const formatValue = (value) => {
}
.rank-labels {
flex: 0 0 auto;
flex: 0 0 min(34%, 150px);
min-width: 112px;
display: flex;
flex-direction: column;
justify-content: space-around;
@@ -214,10 +222,24 @@ const formatValue = (value) => {
display: block;
}
.rank-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.rank-name {
color: #475569;
font-size: 13px;
font-weight: 500;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-meta {
color: #94a3b8;
font-size: 11px;
font-weight: 650;
}
.chart-area {

View File

@@ -1,9 +1,14 @@
<template>
<section class="risk-observation-dashboard">
<section class="risk-observation-dashboard" :class="{ 'is-loading': loading }">
<div v-if="loading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ loadingLabel }}</span>
</div>
<article class="panel dashboard-card risk-trend-panel">
<div class="card-head">
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
<div class="risk-window-controls">
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
<span class="risk-window-label"> {{ dashboard.windowDays }} </span>
<EnterpriseSelect
class="risk-window-select"
@@ -174,11 +179,29 @@ const props = defineProps({
signalRanking: { type: Array, default: () => [] },
dailyRows: { type: Array, default: () => [] },
windowOptions: { type: Array, default: () => [] },
activeWindowDays: { type: Number, default: 30 }
activeWindowDays: { type: Number, default: 30 },
lastUpdatedAt: { type: String, default: '' }
})
const emit = defineEmits(['update:windowDays'])
const router = useRouter()
const loadingLabel = computed(() => (
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
))
const lastUpdatedLabel = computed(() => {
if (!props.lastUpdatedAt) {
return ''
}
const date = new Date(props.lastUpdatedAt)
if (Number.isNaN(date.getTime())) {
return ''
}
return `上次同步 ${date.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})}`
})
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
const dimensionGroups = computed(() => [
@@ -315,12 +338,39 @@ function openClaim(item) {
<style scoped>
.risk-observation-dashboard {
position: relative;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 18px;
min-width: 0;
}
.risk-dashboard-loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: rgba(248, 250, 252, .82);
color: #334155;
font-size: 13px;
font-weight: 800;
backdrop-filter: blur(2px);
}
.risk-dashboard-loading-overlay i {
color: var(--theme-primary);
font-size: 26px;
}
.risk-observation-dashboard.is-loading .dashboard-card {
pointer-events: none;
}
.dashboard-card {
min-width: 0;
padding: 18px;
@@ -359,6 +409,13 @@ function openClaim(item) {
font-weight: 700;
}
.risk-refresh-label {
flex: 0 0 auto;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.risk-window-controls {
flex: 0 0 auto;
display: flex;

View File

@@ -1,5 +1,5 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat }">
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<div class="title-group">
<div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1>
@@ -121,12 +121,73 @@
</div>
</template>
<template v-else-if="isWorkbench">
<template v-else-if="isWorkbench">
<div class="topbar-toolset" aria-label="工作台快捷工具">
<button class="topbar-icon-btn notification-btn" type="button" aria-label="通知">
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
</button>
<div class="notification-wrap">
<button
class="topbar-icon-btn notification-btn"
type="button"
aria-label="通知"
:aria-expanded="notificationOpen"
aria-haspopup="dialog"
@click="notificationOpen = !notificationOpen"
>
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
</button>
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
<header class="notification-head">
<strong>通知</strong>
<button type="button" aria-label="关闭通知" @click="notificationOpen = false">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="notification-tabs" role="tablist" aria-label="通知状态">
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'unread'"
:class="{ active: notificationTab === 'unread' }"
@click="notificationTab = 'unread'"
>
未读 {{ unreadNotifications.length }}
</button>
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'read'"
:class="{ active: notificationTab === 'read' }"
@click="notificationTab = 'read'"
>
已读 {{ readNotifications.length }}
</button>
</div>
<div v-if="activeNotifications.length" class="notification-list">
<button
v-for="item in activeNotifications"
:key="item.id"
type="button"
class="notification-row"
@click="openNotification(item)"
>
<span class="notification-dot" :class="item.tone"></span>
<span class="notification-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
<em>{{ item.time }}</em>
</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div v-else class="notification-empty">
<i class="mdi mdi-bell-check-outline"></i>
<span>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</span>
</div>
</div>
</div>
<button class="topbar-icon-btn" type="button" aria-label="帮助">
<i class="mdi mdi-help-circle-outline"></i>
@@ -243,6 +304,10 @@ const props = defineProps({
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
@@ -276,7 +341,8 @@ const emit = defineEmits([
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication'
'newApplication',
'openDocument'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
@@ -294,10 +360,34 @@ const eyebrowLabel = computed(() => (
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const topbarNotificationCount = computed(() => {
const summary = props.documentSummary ?? {}
const count = Number(summary.toProcess ?? summary.toSubmit ?? 8)
const summary = props.workbenchSummary ?? {}
const count = Number(summary.unreadNotificationCount ?? 0)
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
})
})
const notificationOpen = ref(false)
const notificationTab = ref('unread')
const notificationItems = computed(() => (
Array.isArray(props.workbenchSummary?.notifications)
? props.workbenchSummary.notifications
: []
))
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
function openNotification(item) {
notificationOpen.value = false
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', {
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
}
}
const requestKpis = computed(() => {
const summary = props.requestSummary ?? {}

View File

@@ -99,7 +99,6 @@
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pages"
:show-page-size="showPageSize"
:summary="summary"
:total="total"
@@ -139,10 +138,6 @@ const props = defineProps({
default: () => []
},
panel: { type: Boolean, default: true },
pages: {
type: Array,
default: () => []
},
retryLabel: { type: String, default: '重新加载' },
searchable: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '搜索' },

View File

@@ -78,10 +78,6 @@ const props = defineProps({
type: Array,
default: () => []
},
pages: {
type: Array,
default: () => []
},
showPageSize: { type: Boolean, default: true },
summary: { type: String, default: '' },
total: { type: Number, default: 0 },
@@ -106,7 +102,7 @@ const summaryText = computed(() => {
return props.summary
}
return `${props.total} 条,当前第 ${props.currentPage}`
return `${props.total} 条,当前第 ${props.currentPage} / ${props.totalPages}`
})
function setPage(page) {
@@ -142,3 +138,140 @@ watch(
}
)
</script>
<style scoped>
.list-foot.enterprise-pagination {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 16px;
margin-top: 12px;
}
.enterprise-pagination .page-summary {
min-width: 0;
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.enterprise-pagination .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.enterprise-pagination .pager button {
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 3px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.enterprise-pagination .pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
}
.enterprise-pagination .pager button.active {
background: var(--theme-primary);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.enterprise-pagination .pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.enterprise-pagination .page-ellipsis {
width: 28px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 13px;
font-weight: 850;
}
.enterprise-pagination .page-tools {
justify-self: end;
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.enterprise-pagination .page-size-select {
width: 112px;
}
.enterprise-pagination .page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
color: #64748b;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.enterprise-pagination .page-jump input {
width: 54px;
height: 32px;
padding: 0 8px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
font-weight: 800;
text-align: center;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.enterprise-pagination .page-jump input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
}
@media (max-width: 760px) {
.list-foot.enterprise-pagination {
grid-template-columns: 1fr;
justify-items: stretch;
}
.enterprise-pagination .pager {
width: 100%;
max-width: 100%;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: thin;
}
.enterprise-pagination .pager button,
.enterprise-pagination .page-ellipsis {
flex: 0 0 auto;
}
.enterprise-pagination .page-tools {
justify-self: stretch;
justify-content: space-between;
flex-wrap: wrap;
}
}
</style>

View File

@@ -34,12 +34,15 @@ export function useAppShell() {
conversation: null,
scope: null,
sessionType: '',
budgetContext: null
budgetContext: null,
initialPromptAutoSubmit: true,
initialApplicationPreview: null
})
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null)
const selectedRequestSnapshot = ref(null)
const documentCenterRefreshToken = ref(0)
const { activeView, currentView, setView } = useNavigation()
const {
@@ -98,12 +101,19 @@ export function useAppShell() {
: []
))
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
async function reloadDocumentCenterRequests() {
documentCenterRefreshToken.value += 1
return reloadRequests()
}
watch(
requestsNeeded,
(isNeeded) => {
if (isNeeded) {
() => [activeView.value, route.name],
([view]) => {
if (view === 'documents') {
void reloadDocumentCenterRequests()
return
}
if (view === 'workbench') {
void ensureRequestsLoaded()
}
},
@@ -166,10 +176,17 @@ export function useAppShell() {
toast(message)
}
function handleNavigate(view) {
smartEntryOpen.value = false
setView(view)
}
function handleNavigate(view) {
smartEntryOpen.value = false
const shouldRefreshCurrentDocumentCenter =
view === 'documents'
&& activeView.value === 'documents'
&& route.name === 'app-documents'
setView(view)
if (shouldRefreshCurrentDocumentCenter) {
void reloadDocumentCenterRequests()
}
}
function openFinancialAssistantCreate(source) {
if (smartEntryOpen.value) {
@@ -185,7 +202,9 @@ export function useAppShell() {
conversation: null,
scope: null,
sessionType: '',
budgetContext: null
budgetContext: null,
initialPromptAutoSubmit: true,
initialApplicationPreview: null
}
smartEntrySessionId.value += 1
}
@@ -320,6 +339,7 @@ export function useAppShell() {
|| String(payload?.prompt || '').trim()
|| (Array.isArray(payload?.files) && payload.files.length)
|| payload?.conversation
|| payload?.applicationPreview
)
if (smartEntryOpen.value && !shouldReplaceOpenEntry) {
smartEntryRevealToken.value += 1
@@ -342,6 +362,10 @@ export function useAppShell() {
sessionType,
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
? payload.budgetContext
: null,
initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false,
initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object'
? payload.applicationPreview
: null
}
smartEntrySessionId.value += 1
@@ -410,6 +434,7 @@ export function useAppShell() {
currentView,
customRange,
detailMode,
documentCenterRefreshToken,
filteredRequests,
filters,
handleApprove,
@@ -429,6 +454,7 @@ export function useAppShell() {
requestsError,
requestsLoading,
reloadRequests,
reloadDocumentCenterRequests,
requests,
search,
selectedRequest,

View File

@@ -1,4 +1,4 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
fetchDigitalEmployeeDashboard,
@@ -92,6 +92,9 @@ export function useOverviewView(options = {}) {
const riskDashboardPayload = ref(null)
const riskDashboardLoading = ref(false)
const riskDashboardError = ref(null)
const riskDashboardLastUpdatedAt = ref('')
let riskDashboardRefreshTimer = 0
let riskDashboardRequestSeq = 0
const digitalEmployeeDashboardPayload = ref(null)
const digitalEmployeeDashboardLoading = ref(false)
const digitalEmployeeDashboardError = ref(null)
@@ -178,22 +181,53 @@ export function useOverviewView(options = {}) {
}
const loadRiskDashboard = async () => {
const requestSeq = ++riskDashboardRequestSeq
riskDashboardLoading.value = true
riskDashboardError.value = null
try {
riskDashboardPayload.value = await fetchRiskObservationDashboard({
const payload = await fetchRiskObservationDashboard({
windowDays: activeRiskWindowDays.value,
limit: 500
})
if (requestSeq !== riskDashboardRequestSeq) {
return
}
riskDashboardPayload.value = payload
riskDashboardLastUpdatedAt.value = new Date().toISOString()
} catch (error) {
if (requestSeq !== riskDashboardRequestSeq) {
return
}
riskDashboardPayload.value = null
riskDashboardError.value = error
} finally {
riskDashboardLoading.value = false
if (requestSeq === riskDashboardRequestSeq) {
riskDashboardLoading.value = false
}
}
}
const startRiskDashboardRealtimeRefresh = () => {
if (riskDashboardRefreshTimer) {
window.clearInterval(riskDashboardRefreshTimer)
}
riskDashboardRefreshTimer = window.setInterval(() => {
if (document.visibilityState === 'hidden' || riskDashboardLoading.value) {
return
}
void loadRiskDashboard()
}, 30_000)
}
const stopRiskDashboardRealtimeRefresh = () => {
if (!riskDashboardRefreshTimer) {
return
}
window.clearInterval(riskDashboardRefreshTimer)
riskDashboardRefreshTimer = 0
}
const loadDigitalEmployeeDashboard = async () => {
digitalEmployeeDashboardLoading.value = true
digitalEmployeeDashboardError.value = null
@@ -222,6 +256,11 @@ export function useOverviewView(options = {}) {
void loadSystemDashboard()
void loadRiskDashboard()
void loadDigitalEmployeeDashboard()
startRiskDashboardRealtimeRefresh()
})
onBeforeUnmount(() => {
stopRiskDashboardRealtimeRefresh()
})
watch(
@@ -323,6 +362,9 @@ export function useOverviewView(options = {}) {
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || []
))
const financeDepartmentEmployeeMix = computed(() => (
financeDashboardPayload.value?.departmentEmployeeMix || emptyFinanceDonut
))
const financeEmployeeRanking = computed(() => (
financeDashboardPayload.value?.employeeRanking || []
))
@@ -501,7 +543,11 @@ export function useOverviewView(options = {}) {
const activeTrend = computed(() => financeTrend.value)
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const departmentEmployeeTotal = computed(() => (
financeDepartmentEmployeeMix.value.reduce((sum, item) => sum + Number(item.value || item.amount || 0), 0)
))
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
const departmentEmployeeCenterValue = computed(() => formatCurrency(Math.round(departmentEmployeeTotal.value)))
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
...item,
@@ -513,6 +559,14 @@ export function useOverviewView(options = {}) {
display: `${item.value}`
})))
const departmentEmployeeLegend = computed(() => financeDepartmentEmployeeMix.value.map((item) => ({
...item,
value: Number(item.value || item.amount || 0),
display: departmentEmployeeTotal.value
? `${Math.round((Number(item.value || item.amount || 0) / departmentEmployeeTotal.value) * 100)}%`
: '0%'
})))
const systemToolTotal = computed(() =>
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
)
@@ -542,6 +596,7 @@ export function useOverviewView(options = {}) {
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
meta: `${Number(item.employeeCount || 0)} 人 / ${Number(item.count || 0)}`,
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
@@ -561,6 +616,7 @@ export function useOverviewView(options = {}) {
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
meta: `${item.department || '未归属部门'} / ${Number(item.count || 0)}`,
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
@@ -738,6 +794,8 @@ export function useOverviewView(options = {}) {
bottlenecks,
budgetMetrics,
budgetSummary,
departmentEmployeeCenterValue,
departmentEmployeeLegend,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
@@ -760,6 +818,7 @@ export function useOverviewView(options = {}) {
rankedEmployees,
riskDashboard,
riskDashboardError,
riskDashboardLastUpdatedAt,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,

View File

@@ -163,7 +163,7 @@ export const exceptionMix = [
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
]
export const departmentRangeOptions = ['本周', '本月', '本季度']
export const departmentRangeOptions = ['本月', '本季度', '本年', '全部']
export const bottlenecks = [
{

View File

@@ -20,6 +20,7 @@ const FINANCE_DASHBOARD_FALLBACK = {
spendByCategory: null,
exceptionMix: null,
departmentRanking: null,
departmentEmployeeMix: null,
employeeRanking: null,
topClaims: null,
bottlenecks: null,
@@ -69,6 +70,7 @@ function normalizeFinanceDashboardPayload(payload = {}) {
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
departmentEmployeeMix: payload.department_employee_mix || payload.departmentEmployeeMix || null,
employeeRanking: payload.employee_ranking || payload.employeeRanking || null,
topClaims: payload.top_claims || payload.topClaims || null,
bottlenecks: payload.bottlenecks || null,
@@ -129,7 +131,7 @@ export async function fetchFinanceDashboard(options = {}) {
if (options.endDate) search.set('end_date', String(options.endDate))
const payload = await apiRequest(`/analytics/finance-dashboard?${search.toString()}`, {
timeoutMs: Number(options.timeoutMs || 3500),
timeoutMs: Number(options.timeoutMs || 10000),
timeoutMessage: '财务看板真实数据加载超时,已保留本地展示数据。'
})

View File

@@ -121,6 +121,10 @@ function resolveDaysFromDateRange(rangeText) {
return diffDays >= 0 ? `${diffDays + 1}` : ''
}
export function resolveApplicationDaysFromDateRange(rangeText) {
return resolveDaysFromDateRange(rangeText)
}
function resolvePreviewToday(options = {}) {
const explicitToday = String(options.today || options.currentDate || '').trim()
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)

View File

@@ -0,0 +1,119 @@
export const TRAVEL_PLANNING_ACTION_GENERATE = 'generate_travel_application_plan'
export const TRAVEL_PLANNING_ACTION_SKIP = 'skip_travel_application_plan'
function normalizeText(value) {
return String(value || '').trim()
}
function isTravelApplication(applicationType = '') {
return /差旅|出差/.test(normalizeText(applicationType))
}
function extractDateParts(timeText = '') {
const dates = normalizeText(timeText).match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
return {
startDate: dates[0] || '',
endDate: dates[dates.length - 1] || dates[0] || ''
}
}
function resolveTravelPlanningContext(preview = {}, draftPayload = {}) {
const fields = preview?.fields && typeof preview.fields === 'object' ? preview.fields : {}
const applicationType = normalizeText(fields.applicationType)
if (!isTravelApplication(applicationType)) {
return null
}
const location = normalizeText(fields.location)
const time = normalizeText(fields.time)
if (!location || !time) {
return null
}
const dates = extractDateParts(time)
return {
applicationType,
location,
time,
startDate: dates.startDate,
endDate: dates.endDate,
days: normalizeText(fields.days),
transportMode: normalizeText(fields.transportMode),
reason: normalizeText(fields.reason),
claimNo: normalizeText(draftPayload?.claim_no || draftPayload?.claimNo)
}
}
export function buildTravelPlanningNudgeMessage(preview = {}, draftPayload = {}) {
const context = resolveTravelPlanningContext(preview, draftPayload)
if (!context) {
return ''
}
const timeCopy = context.startDate && context.endDate && context.startDate !== context.endDate
? `${context.startDate}${context.endDate}`
: context.time
const transportCopy = context.transportMode ? `${context.transportMode}时间窗口` : '、交通方式比选'
return [
`本次${context.location}差旅申请已经提交。`,
`如果你愿意,我可以继续按 ${timeCopy} 帮你整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议和还需要确认的事项。`
].join('\n')
}
export function buildTravelPlanningSuggestedActions(preview = {}, draftPayload = {}) {
const context = resolveTravelPlanningContext(preview, draftPayload)
if (!context) {
return []
}
return [
{
label: '生成行程规划',
action_type: TRAVEL_PLANNING_ACTION_GENERATE,
description: '按本次申请的地点和时间给出交通、酒店和待确认事项。',
icon: 'mdi mdi-map-clock-outline',
emphasis: 'primary',
payload: {
context
}
},
{
label: '暂不需要',
action_type: TRAVEL_PLANNING_ACTION_SKIP,
description: '保留申请结果,不继续生成规划。',
icon: 'mdi mdi-check-outline',
payload: {
context
}
}
]
}
export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {}) {
const context = resolveTravelPlanningContext(preview, draftPayload)
if (!context) {
return ''
}
const outboundDate = context.startDate || '出发当天'
const returnDate = context.endDate || '返回当天'
const transport = context.transportMode || '火车/飞机'
const reasonLine = context.reason ? `业务安排:${context.reason}` : '业务安排:以申请事由为准,出发前再确认具体到场时间。'
const hotelArea = `${context.location}核心办公区、客户现场周边或交通枢纽 30 分钟通勤范围内`
const claimLine = context.claimNo ? `关联申请单:${context.claimNo}` : ''
return [
'可以,先给你一版轻量行程规划,后续你可以继续补充偏好。',
'',
claimLine,
`行程时间:${context.time}${context.days ? `${context.days}` : ''}`,
reasonLine,
'',
`交通建议:${outboundDate} 优先看上午到中午抵达 ${context.location}${transport}班次,预留到达后 1.5 小时交通和现场准备时间;${returnDate} 优先看下午或晚间返程,避免压缩最后一天工作安排。`,
`酒店建议:优先选择${hotelArea},同时关注可开发票、可取消、早餐和离现场距离。`,
'需要确认:出发城市、客户现场地址、是否需要同行人、是否有指定住宿协议酒店、是否需要提前准备会议室或网络环境。',
'',
'你也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。'
].filter(Boolean).join('\n')
}

View File

@@ -3,6 +3,10 @@ function parseNumber(value) {
return Number.isFinite(nextValue) ? nextValue : 0
}
function normalizeText(value) {
return String(value ?? '').trim()
}
function toDate(value) {
if (!value) {
return null
@@ -60,6 +64,174 @@ function formatCurrency(value) {
}).format(parseNumber(value))
}
function resolveRequestIdentity(request) {
return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId)
}
function resolveRequestTarget(request) {
return {
type: 'document',
id: normalizeText(request?.claimId || request?.id),
claimNo: resolveRequestIdentity(request)
}
}
function resolveStatusTone(approvalKey) {
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
if (approvalKey === 'draft') return 'success'
if (approvalKey === 'pending_payment') return 'warning'
if (approvalKey === 'in_progress') return 'info'
return 'muted'
}
function resolveTodoAction(request) {
const approvalKey = normalizeText(request?.approvalKey)
const status = normalizeText(request?.status || request?.approvalStatus)
if (approvalKey === 'supplement' || approvalKey === 'rejected') {
return {
title: '补充或修改单据',
status: approvalKey === 'rejected' ? '退回修改' : '待补充',
statusTone: 'danger',
iconKey: 'receipts',
color: 'var(--danger)',
accent: 'var(--danger-soft)'
}
}
if (approvalKey === 'draft' || /draft|草稿|待提交/i.test(status)) {
return {
title: '提交草稿单据',
status: '待提交',
statusTone: 'success',
iconKey: 'travelDraft',
color: 'var(--theme-primary)',
accent: 'var(--theme-primary-soft)'
}
}
return null
}
function buildTodoItems(ownedRequests) {
return ownedRequests
.map((request) => {
const action = resolveTodoAction(request)
if (!action) {
return null
}
const requestId = resolveRequestIdentity(request)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || requestId
return {
...action,
id: requestId,
requestId,
title: action.title,
description: `${requestId || '单据'} · ${title || '费用单据'}`,
due: normalizeText(request?.updatedAt || request?.applyTime || request?.submittedAt) || '待处理',
target: resolveRequestTarget(request),
prompt: `帮我处理 ${requestId || title}${action.status}`
}
})
.filter(Boolean)
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
}
function resolveProgressStatusTone(approvalKey) {
if (approvalKey === 'completed') return 'muted'
if (approvalKey === 'pending_payment') return 'warning'
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
return 'success'
}
function resolveCurrentProgressIndex(steps) {
const currentIndex = steps.findIndex((step) => step?.current)
if (currentIndex >= 0) {
return currentIndex
}
const activeIndex = steps.findLastIndex((step) => step?.active || step?.done)
return Math.max(0, activeIndex)
}
export function buildAdjacentProgressSteps(steps = [], windowSize = 4) {
const rows = Array.isArray(steps) ? steps : []
if (!rows.length) {
return []
}
const currentIndex = resolveCurrentProgressIndex(rows)
const safeWindowSize = Math.max(1, Number(windowSize) || 4)
let start = Math.max(0, currentIndex - 1)
let end = Math.min(rows.length, start + safeWindowSize)
if (end - start < safeWindowSize) {
start = Math.max(0, end - safeWindowSize)
}
return rows.slice(start, end).map((step) => ({
label: normalizeText(step.label || step.rawLabel),
done: Boolean(step.done),
current: Boolean(step.current),
title: normalizeText(step.title || step.time || step.detail)
}))
}
function buildProgressItems(ownedRequests) {
return ownedRequests
.filter((request) => Array.isArray(request?.progressSteps) && request.progressSteps.length)
.map((request) => {
const requestId = resolveRequestIdentity(request)
const steps = buildAdjacentProgressSteps(request.progressSteps, 4)
const currentStep = steps.find((step) => step.current)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
return {
id: requestId,
requestId,
title,
amount: formatCurrency(request?.amount),
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中',
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
steps,
target: resolveRequestTarget(request),
prompt: `查询 ${requestId || title} 的费用进度`
}
})
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
}
function buildNotifications(todoItems, progressItems) {
const todoNotifications = todoItems.map((item) => ({
id: `todo:${item.requestId || item.description}`,
title: item.status,
description: item.description,
time: item.due,
unread: true,
tone: item.statusTone,
target: item.target,
prompt: item.prompt
}))
const progressNotifications = progressItems
.filter((item) => ['danger', 'warning'].includes(item.statusTone))
.map((item) => ({
id: `progress:${item.requestId || item.title}`,
title: item.status,
description: `${item.requestId || '单据'} · ${item.title}`,
time: item.updatedAt || '最近更新',
unread: false,
tone: item.statusTone,
target: item.target,
prompt: item.prompt
}))
return [...todoNotifications, ...progressNotifications]
}
export function buildWorkbenchSummary(requests, currentUser) {
const ownedRequests = Array.isArray(requests)
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
@@ -79,6 +251,9 @@ export function buildWorkbenchSummary(requests, currentUser) {
const completedCount = ownedRequests.filter((item) => item.approvalKey === 'completed').length
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
const todoItems = buildTodoItems(ownedRequests)
const progressItems = buildProgressItems(ownedRequests)
const notifications = buildNotifications(todoItems, progressItems)
return {
monthlyCount,
@@ -91,6 +266,10 @@ export function buildWorkbenchSummary(requests, currentUser) {
pendingPaymentCount,
completedCount,
returnCount,
highRiskCount
highRiskCount,
todoItems,
progressItems,
notifications,
unreadNotificationCount: notifications.filter((item) => item.unread).length
}
}

View File

@@ -71,6 +71,7 @@
:knowledge-summary="knowledgeSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:workbench-summary="workbenchSummary"
:digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="resolvedDetailMode"
@@ -84,6 +85,7 @@
@update:overview-dashboard="overviewDashboard = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument"
/>
<FilterBar
@@ -124,6 +126,7 @@
:assistant-modal-open="smartEntryOpen"
:workbench-summary="workbenchSummary"
@open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument"
/>
<TravelRequestDetailView
@@ -142,6 +145,7 @@
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"
:refresh-token="documentCenterRefreshToken"
@open-document="openRequestDetail"
@create-request="openTravelCreate"
@create-application="openExpenseApplicationCreate"
@@ -188,6 +192,8 @@
:initial-conversation="smartEntryContext.conversation"
:initial-session-type="smartEntryContext.sessionType"
:initial-budget-context="smartEntryContext.budgetContext"
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
:initial-application-preview="smartEntryContext.initialApplicationPreview"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
@@ -274,6 +280,7 @@ const {
customRange,
detailAlerts,
detailMode,
documentCenterRefreshToken,
filteredRequests,
filters,
handleApprove,
@@ -292,6 +299,7 @@ const {
workbenchSummary,
requestsError,
requestsLoading,
reloadDocumentCenterRequests,
reloadRequests,
requests,
search,
@@ -351,6 +359,20 @@ const resolvedDetailKpis = computed(() => (
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
))
function openWorkbenchDocument(payload = {}) {
const requestId = String(payload.claimId || payload.id || payload.claimNo || '').trim()
if (!requestId) {
return
}
const request = requests.value.find((item) => (
String(item.claimId || '').trim() === requestId
|| String(item.id || '').trim() === requestId
|| String(item.claimNo || '').trim() === requestId
))
openRequestDetail(request || payload)
}
function handleLogout() {
logout('manual')
}

View File

@@ -196,35 +196,16 @@
</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>
<EnterprisePagination
v-if="showTable"
:current-page="budgetPage"
:page-size="budgetPageSize"
:page-size-options="budgetPageSizeOptions"
:summary="pageSummary"
:total-pages="totalBudgetPages"
@page-size-change="changeBudgetPageSize"
@update:current-page="goToBudgetPage"
/>
</article>
<EnterpriseDetailPage

View File

@@ -154,7 +154,8 @@ import { isPlatformAdminUser } from '../utils/accessControl.js'
import {
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
formatDigitalEmployeeCron,
isDigitalEmployeeAsset
isDigitalEmployeeAsset,
shouldDisplayDigitalEmployeeAsset
} from './scripts/auditViewDigitalEmployeeModel.js'
import {
buildDigitalEmployeeScheduleConfig,
@@ -336,7 +337,9 @@ async function loadEmployees() {
try {
const payload = await fetchAgentAssets({ assetType: 'task' })
const items = Array.isArray(payload)
? payload.filter(isDigitalEmployeeAsset).map(buildEmployeeListItem)
? payload
.filter((asset) => isDigitalEmployeeAsset(asset) && shouldDisplayDigitalEmployeeAsset(asset))
.map(buildEmployeeListItem)
: []
employees.value = sortEmployees(items)

View File

@@ -215,36 +215,23 @@
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="changePageSize" />
</footer>
<EnterprisePagination
v-if="showTable"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</article>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
@@ -318,7 +305,8 @@ const props = defineProps({
filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
error: { type: String, default: '' },
refreshToken: { type: Number, default: 0 }
})
const emit = defineEmits([
'open-document',
@@ -463,6 +451,7 @@ const filteredRows = computed(() => {
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
@@ -832,6 +821,15 @@ watch(documentSummary, (summary) => {
onMounted(() => {
void loadSupportingRows()
})
watch(
() => props.refreshToken,
(token, previousToken) => {
if (token && token !== previousToken) {
void loadSupportingRows()
}
}
)
</script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>

View File

@@ -657,47 +657,16 @@
</table>
</div>
<footer v-if="!loading && !errorMessage && totalCount" class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button
class="page-nav"
type="button"
:disabled="currentPage === 1"
aria-label="上一页"
@click="currentPage--"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button
class="page-nav"
type="button"
:disabled="currentPage === totalPages"
aria-label="下一页"
@click="currentPage++"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
/>
</footer>
<EnterprisePagination
v-if="!loading && !errorMessage && totalCount"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</article>
</Transition>

View File

@@ -14,9 +14,8 @@
:empty="!systemLogLoading && !visibleSystemLogEntries.length"
:total="totalCount"
:total-pages="totalPages"
:pages="visiblePageItems"
:page-size-options="pageSizeOptions"
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} 页`"
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} / ${totalPages} 页`"
:show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0"
loading-title="系统日志同步中"
loading-message="正在加载系统运行日志记录"

View File

@@ -69,7 +69,7 @@
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行费用金额<i class="mdi mdi-information-outline"></i></h3>
<h3>部门报销排行 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
@@ -84,7 +84,14 @@
<article class="panel dashboard-card employee-rank-panel">
<div class="card-head">
<h3>个人报销排行本月<i class="mdi mdi-information-outline"></i></h3>
<h3>个人报销排行 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
:options="departmentRangeOptions"
aria-label="个人排行时间范围"
size="small"
/>
</div>
<BarChart :items="rankedEmployees" />
@@ -92,22 +99,33 @@
<article class="panel dashboard-card top-claim-panel">
<div class="card-head">
<h3>本月高额单据 <i class="mdi mdi-information-outline"></i></h3>
<h3>高额单据 <i class="mdi mdi-information-outline"></i></h3>
<span class="card-range-chip">{{ activeDepartmentRange }}</span>
</div>
<div class="top-claim-list">
<div
v-for="item in topClaims"
:key="item.claimNo"
class="top-claim-row"
>
<div>
<strong>{{ item.claimNo }}</strong>
<span>{{ item.employeeName }} · {{ item.departmentName || '未归属部门' }}</span>
</div>
<div>
<strong>{{ item.amountLabel }}</strong>
<span>{{ item.expenseTypeLabel }} · {{ item.statusLabel }}</span>
<div class="top-claim-split">
<div class="department-employee-mix">
<DonutChart
:items="departmentEmployeeLegend"
:center-value="departmentEmployeeCenterValue"
center-label="人员占比"
/>
</div>
<div class="top-claim-list">
<div
v-for="item in topClaims"
:key="item.claimNo"
class="top-claim-row"
>
<div>
<strong>{{ item.claimNo }}</strong>
<span>{{ item.employeeName }} · {{ item.departmentName || '未归属部门' }}</span>
</div>
<div>
<strong>{{ item.amountLabel }}</strong>
<span>{{ item.expenseTypeLabel }} · {{ item.statusLabel }}</span>
</div>
</div>
</div>
</div>
@@ -158,6 +176,7 @@
:dashboard="riskDashboard"
:loading="riskDashboardLoading"
:error="riskDashboardError"
:last-updated-at="riskDashboardLastUpdatedAt"
:level-legend="riskLevelLegend"
:source-legend="riskSourceLegend"
:signal-ranking="riskSignalRanking"
@@ -358,6 +377,8 @@ const {
activeTrendRange,
budgetMetrics,
budgetSummary,
departmentEmployeeCenterValue,
departmentEmployeeLegend,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
@@ -371,6 +392,7 @@ const {
rankedEmployees,
riskDashboard,
riskDashboardError,
riskDashboardLastUpdatedAt,
riskDashboardLoading,
riskDailyTrendRows,
riskKpiMetrics,

View File

@@ -4,6 +4,7 @@
:assistant-modal-open="assistantModalOpen"
:workbench-summary="workbenchSummary"
@open-assistant="emit('open-assistant', $event)"
@open-document="emit('open-document', $event)"
/>
</template>
@@ -15,5 +16,5 @@ defineProps({
workbenchSummary: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-assistant'])
const emit = defineEmits(['open-assistant', 'open-document'])
</script>

View File

@@ -146,35 +146,15 @@
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
/>
</footer>
<EnterprisePagination
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</section>
</div>
</article>

View File

@@ -112,28 +112,16 @@
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="currentPage = 1" />
</footer>
<EnterprisePagination
v-if="showTable"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:summary="pageSummary"
:total-pages="totalPages"
@page-size-change="changePageSize"
@update:current-page="currentPage = $event"
/>
</article>
<EnterpriseDetailPage
@@ -395,7 +383,7 @@ import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
@@ -477,6 +465,7 @@ const filteredRows = computed(() => {
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
@@ -599,6 +588,11 @@ function switchStatus(status) {
activeStatus.value = status
}
function changePageSize(size) {
pageSize.value = Number(size) || pageSize.value
currentPage.value = 1
}
async function reloadReceipts() {
loading.value = true
error.value = ''

View File

@@ -476,6 +476,16 @@
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : deleteActionLabel }}
</button>
<button
v-if="canModifyReturnedApplication"
class="secondary-action"
type="button"
:disabled="actionBusy"
@click="handleModifyApplication"
>
<i class="mdi mdi-pencil-outline"></i>
修改申请
</button>
<button class="approve-action" type="button" :disabled="!canSubmit" @click="handleSubmit">
<i :class="submitActionIcon"></i>
{{ submitActionLabel }}
@@ -773,3 +783,4 @@
<script src="./scripts/TravelRequestDetailView.js"></script>
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style>

View File

@@ -4,6 +4,7 @@ import { ElButton } from 'element-plus/es/components/button/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
@@ -98,6 +99,7 @@ export default {
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
EnterprisePagination,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
@@ -169,9 +171,6 @@ export default {
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
)
const budgetPageNumbers = computed(() =>
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 8)
const start = (currentBudgetPage.value - 1) * pageSize
@@ -227,7 +226,7 @@ export default {
artLabel: '预算列表为空',
tips: ['可以调整年度、季度、状态或关键词后重试。']
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} / ${totalBudgetPages.value}`)
function buildBudgetAssistantContext(row, mode = 'edit') {
if (!row) return null
@@ -425,7 +424,6 @@ export default {
budgetKeyword,
budgetLoading,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions,
budgetScopeTabs,

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
@@ -452,6 +453,7 @@ export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
EnterprisePagination,
EnterpriseSelect,
TableLoadingState,
TableEmptyState
@@ -672,6 +674,7 @@ export default {
const totalCount = computed(() => filteredEmployees.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const pageSummary = computed(() => `${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
@@ -1469,6 +1472,7 @@ export default {
hasEmployeeFilters,
totalCount,
totalPages,
pageSummary,
resetFilters,
handleEmployeeEmptyAction,
openEmployeeDetail,

View File

@@ -152,12 +152,6 @@ export default {
filteredSystemLogEntries.value.filter((entry) => entry.level === 'INFO').length
)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visiblePageItems = computed(() => {
if (totalPages.value <= 6) {
return Array.from({ length: totalPages.value }, (_, index) => index + 1)
}
return [1, 2, 3, 4, 5, 'ellipsis', totalPages.value]
})
const visibleSystemLogEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
@@ -300,7 +294,6 @@ export default {
systemSearchKeyword,
totalCount,
totalPages,
visiblePageItems,
visibleSystemLogEntries
}
}

View File

@@ -1,7 +1,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -87,7 +87,7 @@ export default {
name: 'PoliciesView',
components: {
ConfirmDialog,
EnterpriseSelect,
EnterprisePagination,
TableLoadingState
},
emits: ['summary-change'],
@@ -182,9 +182,10 @@ export default {
&& activeFolderIngestStats.value.syncing === 0
)
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleDocuments = computed(() => {
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const pageSummary = computed(() => `${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleDocuments = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDocuments.value.slice(start, start + pageSize.value)
})
@@ -636,6 +637,7 @@ export default {
loading,
pageSize,
pageSizeOptions,
pageSummary,
pageSizes,
onlyOfficeError,
onlyOfficeHostId,

View File

@@ -49,6 +49,13 @@ import {
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningNudgeMessage,
buildTravelPlanningRecommendation,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import {
calculateTravelReimbursement,
createExpenseClaimItem,
@@ -524,6 +531,14 @@ export default {
type: String,
default: ''
},
initialPromptAutoSubmit: {
type: Boolean,
default: true
},
initialApplicationPreview: {
type: Object,
default: null
},
initialFiles: {
type: Array,
default: () => []
@@ -629,7 +644,9 @@ export default {
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast
toast,
calculateTravelReimbursement,
currentUser
})
function applyLinkedApplicationPreviewDateSelection(selection) {
@@ -1372,6 +1389,14 @@ export default {
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
meta: ['修改申请'],
applicationPreview
}))
persistSessionState()
}
if (props.initialPrompt?.trim() || props.initialFiles.length) {
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
composerDraft.value = props.initialPrompt.trim()
@@ -1380,7 +1405,12 @@ export default {
if (initialMerge.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
submitComposer()
nextTick(() => {
adjustComposerTextareaHeight()
})
if (props.initialPromptAutoSubmit !== false) {
submitComposer()
}
}
})
@@ -1576,6 +1606,32 @@ export default {
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
if (recommendation) {
messages.value.push(createMessage('user', '生成行程规划'))
messages.value.push(createMessage('assistant', recommendation, [], {
meta: ['行程规划建议']
}))
nextTick(scrollToBottom)
persistSessionState()
}
return
}
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
if (!lockSuggestedActionMessage(message, action)) return
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
meta: ['暂不规划']
}))
nextTick(scrollToBottom)
persistSessionState()
return
}
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
@@ -2033,6 +2089,17 @@ export default {
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
@@ -2044,6 +2111,7 @@ export default {
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
@@ -2059,7 +2127,16 @@ export default {
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
@@ -2074,6 +2151,23 @@ export default {
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
} finally {
reviewActionBusy.value = false
}

View File

@@ -460,11 +460,17 @@ export default {
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canModifyReturnedApplication = computed(() => (
isApplicationDocument.value
&& isEditableRequest.value
&& isCurrentApplicant.value
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value)
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
@@ -1007,7 +1013,7 @@ export default {
if (analysis) {
return {
label: analysis.label || '已上传',
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
tone: normalizeRiskTone(analysis.severity || 'low'),
headline: analysis.headline || 'AI提示',
summary: analysis.summary || '',
points: Array.isArray(analysis.points) ? analysis.points : [],
@@ -1858,7 +1864,9 @@ export default {
toast(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。'
: isApplicationDocument.value
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。'
)
return
}
@@ -2019,6 +2027,76 @@ export default {
})
}
function buildApplicationEditPreview() {
const factEntries = applicationDetailFactItems.value
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
.filter(([label, value]) => label && value)
const facts = new Map(factEntries)
const pickFact = (...labels) => {
for (const label of labels) {
const value = facts.get(label)
if (value) {
return value
}
}
return ''
}
const tripStart = pickFact('出发时间')
const tripReturn = pickFact('返回时间')
const time = tripStart && tripReturn && tripStart !== tripReturn
? `${tripStart}${tripReturn}`
: pickFact('行程时间', '申请时间', '招待时间', '发生时间') || tripStart
return {
sourceText: '修改申请',
modelReviewStatus: 'template',
fields: {
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
grade: pickFact('职级') || request.value.profileGrade || '',
department: request.value.profileDepartment || request.value.departmentName || request.value.department || '',
position: request.value.profilePosition || request.value.employeePosition || request.value.position || '',
managerName: request.value.profileManager || request.value.managerName || request.value.manager || '',
time,
location: pickFact('地点') || request.value.location || request.value.city || '',
reason: pickFact('事由') || request.value.reason || '',
days: pickFact('天数'),
transportMode: pickFact('出行方式'),
lodgingDailyCap: pickFact('住宿上限/天'),
subsidyDailyCap: pickFact('补贴标准/天'),
transportPolicy: pickFact('交通费用口径'),
policyEstimate: pickFact('规则测算参考'),
amount: pickFact('系统预估费用', '用户预估费用', '预计金额') || request.value.amountDisplay || request.value.amount || ''
}
}
}
function handleModifyApplication() {
if (!canModifyReturnedApplication.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'application',
sessionType: 'application',
prompt: '',
applicationPreview: buildApplicationEditPreview(),
request: {
...request.value,
applicationEditMode: true
},
restoreLatestConversation: false,
initialPromptAutoSubmit: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
})
}
onBeforeUnmount(() => {
closeAttachmentPreview()
})
@@ -2032,7 +2110,7 @@ export default {
applicationDetailFactItems, relatedApplicationFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
@@ -2046,6 +2124,7 @@ export default {
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
handleModifyApplication,
handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,

View File

@@ -1,10 +1,28 @@
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整理', '评估']
export const DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES = new Set([
'finance_dashboard_snapshot',
'digital_employee_reminder_scan',
'employee_behavior_profile_scan',
'department_expense_baseline_accumulate',
'budget_overrun_precontrol_evaluate',
'multi_evidence_consistency_evaluate',
'travel_spatiotemporal_consistency_evaluate',
'global_risk_scan',
'finance_policy_knowledge_organize'
])
const TASK_TYPE_LABELS = {
finance_dashboard_snapshot: '财务经营快照沉淀',
digital_employee_reminder_scan: '定时提醒与待办扫描',
daily_risk_scan: '每日风险巡检',
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
department_expense_baseline_accumulate: '部门费用基线沉淀',
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
weekly_ar_summary: '周度应收账龄汇总',
weekly_expense_report: '周度费用洞察',
rule_review_digest: '规则待审摘要',
@@ -15,9 +33,15 @@ const TASK_TYPE_LABELS = {
}
const TASK_TYPE_SKILL_CATEGORIES = {
finance_dashboard_snapshot: '整理',
digital_employee_reminder_scan: '升级',
daily_risk_scan: '评估',
global_risk_scan: '评估',
employee_behavior_profile_scan: '评估',
employee_behavior_profile_scan: '积累',
department_expense_baseline_accumulate: '积累',
budget_overrun_precontrol_evaluate: '评估',
multi_evidence_consistency_evaluate: '评估',
travel_spatiotemporal_consistency_evaluate: '评估',
weekly_ar_summary: '整理',
weekly_expense_report: '整理',
rule_review_digest: '升级',
@@ -145,6 +169,12 @@ export function isDigitalEmployeeAsset(source = {}) {
)
}
export function shouldDisplayDigitalEmployeeAsset(source = {}) {
const content = parseDigitalEmployeeContent(source.current_version_content)
const taskType = resolveDigitalEmployeeTaskType(source, content)
return DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES.has(taskType)
}
export function formatDigitalEmployeeCron(value) {
const raw = normalizeDigitalEmployeeText(value)
if (!raw) {

View File

@@ -18,9 +18,31 @@ const KNOWLEDGE_JOB_TYPES = new Set([
'finance_policy_knowledge_organize'
])
export const VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES = new Set([
'finance_dashboard_snapshot',
'digital_employee_reminder_scan',
'employee_behavior_profile_scan',
'department_expense_baseline_accumulate',
'budget_overrun_precontrol_evaluate',
'multi_evidence_consistency_evaluate',
'travel_spatiotemporal_consistency_evaluate',
'global_risk_scan',
'finance_policy_knowledge_organize'
])
const DAILY_COMPACT_TASK_TYPES = new Set([
'finance_dashboard_snapshot'
])
const TASK_TYPE_LABELS = {
finance_dashboard_snapshot: '财务经营快照沉淀',
digital_employee_reminder_scan: '定时提醒与待办扫描',
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
department_expense_baseline_accumulate: '部门费用基线沉淀',
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
risk_clue_collect: '风险线索归集',
finance_policy_knowledge_organize: '知识制度整理',
knowledge_index_sync: '知识制度整理',
@@ -29,10 +51,16 @@ const TASK_TYPE_LABELS = {
}
const TASK_CODE_TO_TYPE = {
'task.hermes.finance_dashboard_snapshot': 'finance_dashboard_snapshot',
'task.hermes.digital_employee_reminder_scan': 'digital_employee_reminder_scan',
'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'
'task.hermes.department_expense_baseline_accumulate': 'department_expense_baseline_accumulate',
'task.hermes.budget_overrun_precontrol_evaluate': 'budget_overrun_precontrol_evaluate',
'task.hermes.multi_evidence_consistency_evaluate': 'multi_evidence_consistency_evaluate',
'task.hermes.travel_spatiotemporal_consistency_evaluate': 'travel_spatiotemporal_consistency_evaluate',
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize',
'task.hermes.risk_rule_discovery': 'risk_clue_collect'
}
function toObject(value) {
@@ -52,6 +80,12 @@ function resolveTaskTypeFromToolName(value) {
if (name.includes('financial_risk_graph')) {
return 'global_risk_scan'
}
if (name.includes('finance_dashboard_snapshot') || name.includes('finance_dashboard')) {
return 'finance_dashboard_snapshot'
}
if (name.includes('digital_employee_reminder') || name.includes('reminder')) {
return 'digital_employee_reminder_scan'
}
if (name.includes('employee_behavior_profile')) {
return 'employee_behavior_profile_scan'
}
@@ -128,6 +162,43 @@ export function resolveWorkRecordTaskType(run) {
return ''
}
export function isVisibleDigitalEmployeeWorkRecord(run) {
const taskType = resolveWorkRecordTaskType(run)
return VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)
}
function resolveWorkRecordDayKey(run) {
const date = new Date(run?.started_at || run?.finished_at || '')
if (Number.isNaN(date.getTime())) {
return 'unknown'
}
return date.toISOString().slice(0, 10)
}
export function compactDigitalEmployeeWorkRecords(items = []) {
const rows = []
const compactedKeys = new Set()
for (const run of items) {
const taskType = resolveWorkRecordTaskType(run)
if (!VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)) {
continue
}
if (DAILY_COMPACT_TASK_TYPES.has(taskType)) {
const key = `${taskType}:${resolveWorkRecordDayKey(run)}`
if (compactedKeys.has(key)) {
continue
}
compactedKeys.add(key)
}
rows.push(run)
}
return rows
}
export function resolveWorkRecordTaskLabel(run) {
const taskType = resolveWorkRecordTaskType(run)
return TASK_TYPE_LABELS[taskType] || ''
@@ -135,6 +206,12 @@ export function resolveWorkRecordTaskLabel(run) {
export function resolveWorkRecordProductKind(run) {
const taskType = resolveWorkRecordTaskType(run)
if (taskType === 'finance_dashboard_snapshot') {
return 'finance_snapshot'
}
if (taskType === 'digital_employee_reminder_scan') {
return 'reminder_scan'
}
if (taskType === 'global_risk_scan') {
return 'risk_graph'
}

View File

@@ -458,6 +458,7 @@ export function sanitizeRequest(request) {
const normalized = {
claimId: String(request.claimId || request.claim_id || '').trim(),
claimNo: String(request.claimNo || request.claim_no || request.documentNo || '').trim(),
id: String(request.id || '').trim(),
typeLabel: String(request.typeLabel || request.category || '').trim(),
reason: String(request.reason || request.title || '').trim(),
@@ -468,7 +469,8 @@ export function sanitizeRequest(request) {
amount: String(request.amount || '').trim(),
node: String(request.node || '').trim(),
approval: String(request.approval || '').trim(),
travel: String(request.travel || '').trim()
travel: String(request.travel || '').trim(),
applicationEditMode: Boolean(request.applicationEditMode || request.application_edit_mode)
}
return Object.values(normalized).some(Boolean) ? normalized : null

View File

@@ -150,7 +150,7 @@ function resolveRequestBusinessStage(request = {}) {
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'

View File

@@ -2,9 +2,13 @@ import { ref } from 'vue'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPreviewRows,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview,
resolveApplicationDaysFromDateRange,
refreshApplicationPreviewTransportEstimate
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
@@ -44,6 +48,27 @@ function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
}
function resolveEditorCurrentUser(currentUser) {
if (currentUser && typeof currentUser === 'object' && 'value' in currentUser) {
return currentUser.value || {}
}
return currentUser || {}
}
function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue = '') {
const nextFields = {
...fields,
[editor.fieldKey]: nextValue
}
if (editor.fieldKey === 'time') {
const resolvedDays = resolveApplicationDaysFromDateRange(nextValue)
if (resolvedDays) {
nextFields.days = resolvedDays
}
}
return nextFields
}
function buildTransportEstimatePendingPreview(preview = {}) {
const fields = preview?.fields || {}
return normalizeApplicationPreview({
@@ -57,9 +82,29 @@ function buildTransportEstimatePendingPreview(preview = {}) {
})
}
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
export function useApplicationPreviewEditor({
persistSessionState,
toast,
calculateTravelReimbursement,
currentUser
} = {}) {
const applicationPreviewEditor = ref(buildEmptyEditor())
async function refreshApplicationPreviewEstimate(preview = {}) {
const user = resolveEditorCurrentUser(currentUser)
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
if (estimateRequest.canCalculate && typeof calculateTravelReimbursement === 'function') {
try {
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
console.warn('Application preview estimate refresh failed:', error)
return applyApplicationPolicyEstimateError(preview, error, user)
}
}
return refreshApplicationPreviewTransportEstimate(preview)
}
function resolveApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
@@ -158,25 +203,29 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
}
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: {
...(message.applicationPreview.fields || {}),
[editor.fieldKey]: nextValue
}
fields: buildEditedApplicationPreviewFields(
message.applicationPreview.fields || {},
editor,
nextValue
)
})
const needRefreshTransport = shouldRefreshTransportEstimate(editor.fieldKey) && String(nextPreview.fields?.transportMode || '').trim()
message.applicationPreview = needRefreshTransport
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
const transportMode = String(nextPreview.fields?.transportMode || '').trim()
message.applicationPreview = needRefreshEstimate
? 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)
if (needRefreshEstimate) {
if (transportMode) {
await waitForMockApplicationTransportQuote({
transportMode,
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
time: nextPreview.fields.time
})
}
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
message.applicationPreview = refreshedPreview
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
persistSessionState?.()

View File

@@ -207,6 +207,7 @@ export function useTravelReimbursementSessionState({
shouldPersistLocalSnapshot
&& props.entrySource !== 'budget'
&& !String(props.initialPrompt || '').trim()
&& !props.initialApplicationPreview
&& !props.initialFiles.length
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
const persistedInitialState = canRestorePersistedInitialState

View File

@@ -35,6 +35,10 @@ const assistantSubmitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const assistantSessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const assistantTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
@@ -60,6 +64,17 @@ test('application and reimbursement entries open the same financial assistant mo
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
})
test('documents center reloads immediately when entered or clicked again', () => {
assert.match(appShellRouteView, /:refresh-token="documentCenterRefreshToken"/)
assert.match(appShellRouteView, /@reload="reloadRequests"/)
assert.match(appShellComposable, /const documentCenterRefreshToken = ref\(0\)/)
assert.match(appShellComposable, /async function reloadDocumentCenterRequests\(\) \{[\s\S]*documentCenterRefreshToken\.value \+= 1[\s\S]*return reloadRequests\(\)/)
assert.match(appShellComposable, /if \(view === 'documents'\) \{[\s\S]*void reloadDocumentCenterRequests\(\)/)
assert.match(appShellComposable, /shouldRefreshCurrentDocumentCenter[\s\S]*route\.name === 'app-documents'[\s\S]*void reloadDocumentCenterRequests\(\)/)
assert.match(appShellComposable, /documentCenterRefreshToken,/)
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
})
test('application entry keeps its own assistant source without creating a separate dialog', () => {
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
@@ -68,6 +83,32 @@ test('application entry keeps its own assistant source without creating a separa
assert.match(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
})
test('application edit prefill opens assistant without auto submit', () => {
assert.match(appShellRouteView, /:initial-prompt-auto-submit="smartEntryContext\.initialPromptAutoSubmit"/)
assert.match(appShellRouteView, /:initial-application-preview="smartEntryContext\.initialApplicationPreview"/)
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*true/)
assert.match(appShellComposable, /initialApplicationPreview:\s*null/)
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/)
assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === 'object'/)
assert.match(
assistantScript,
/initialPromptAutoSubmit:\s*\{[\s\S]*type:\s*Boolean[\s\S]*default:\s*true/
)
assert.match(
assistantScript,
/initialApplicationPreview:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/
)
assert.match(
assistantScript,
/props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)/
)
assert.match(assistantSessionStateScript, /&& !props\.initialApplicationPreview/)
assert.match(
assistantScript,
/if \(props\.initialPromptAutoSubmit !== false\) \{[\s\S]*submitComposer\(\)/
)
})
test('financial assistant toolbar renders four isolated assistant sessions', () => {
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
assert.match(assistantScript, /visibleModes\.map/)

View File

@@ -4,7 +4,9 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
compactDigitalEmployeeWorkRecords,
extractWorkRecordToolSummary,
isVisibleDigitalEmployeeWorkRecord,
resolveWorkRecordModuleLabel,
resolveWorkRecordProductKind,
resolveWorkRecordTaskType,
@@ -69,6 +71,55 @@ test('digital employee profile run resolves from tool request when route is spar
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
})
test('digital employee finance snapshot and reminder runs are visible core work records', () => {
const financeRun = {
run_id: 'finance-1',
started_at: '2026-06-02T02:00:00+08:00',
route_json: { task_type: 'finance_dashboard_snapshot' },
tool_calls: [{ tool_name: 'digital_employee.finance_dashboard.snapshot' }]
}
const reminderRun = {
run_id: 'reminder-1',
started_at: '2026-06-02T02:05:00+08:00',
route_json: { task_type: 'digital_employee_reminder_scan' },
tool_calls: [{ tool_name: 'digital_employee.reminder.scan' }]
}
assert.equal(resolveWorkRecordModuleLabel(financeRun), '财务经营快照沉淀')
assert.equal(resolveWorkRecordProductKind(financeRun), 'finance_snapshot')
assert.equal(isVisibleDigitalEmployeeWorkRecord(financeRun), true)
assert.equal(resolveWorkRecordModuleLabel(reminderRun), '定时提醒与待办扫描')
assert.equal(resolveWorkRecordProductKind(reminderRun), 'reminder_scan')
assert.equal(isVisibleDigitalEmployeeWorkRecord(reminderRun), true)
})
test('digital employee work records hide support tasks and compact daily finance snapshots', () => {
const rows = compactDigitalEmployeeWorkRecords([
{
run_id: 'finance-new',
started_at: '2026-06-02T03:00:00+08:00',
route_json: { task_type: 'finance_dashboard_snapshot' }
},
{
run_id: 'finance-old',
started_at: '2026-06-02T02:00:00+08:00',
route_json: { task_type: 'finance_dashboard_snapshot' }
},
{
run_id: 'profile-1',
started_at: '2026-06-02T08:30:00+08:00',
route_json: { task_type: 'employee_behavior_profile_scan' }
},
{
run_id: 'support-1',
started_at: '2026-06-02T10:00:00+08:00',
route_json: { task_type: 'risk_clue_collect' }
}
])
assert.deepEqual(rows.map((run) => run.run_id), ['finance-new', 'profile-1'])
})
test('digital employee risk clue run resolves review packet metadata', () => {
const run = {
route_json: {

View File

@@ -85,6 +85,15 @@ test('documents center preserves application document type from mapped requests'
)
})
test('documents center refresh token reloads supporting approval and archive rows', () => {
assert.match(documentsCenterView, /refreshToken:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/)
assert.match(
documentsCenterView,
/watch\(\s*\(\) => props\.refreshToken,[\s\S]*if \(token && token !== previousToken\) \{[\s\S]*void loadSupportingRows\(\)/
)
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
})
test('documents center list shows created time and conditional stay time columns', () => {
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
assert.match(documentsCenterView, /<col class="col-created">/)
@@ -215,7 +224,7 @@ test('documents center switches filter conditions by category tab', () => {
documentsCenterView,
/watch\(activeFilterConfig, \(\) => \{[\s\S]*openFilterKey\.value = ''[\s\S]*datePopover\.value = false/
)
assert.match(documentsCenterView, /<EnterpriseSelect v-model="pageSize"[\s\S]*:options="pageSizeOptions"/)
assert.match(documentsCenterView, /<EnterprisePagination[\s\S]*:page-size="pageSize"[\s\S]*:page-size-options="pageSizeOptions"/)
assert.doesNotMatch(documentsCenterView, /pageSizeOpen/)
})

View File

@@ -24,6 +24,13 @@ import {
resolveMockApplicationTransportWaitMs,
buildSystemApplicationEstimate
} from '../src/utils/expenseApplicationEstimate.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningNudgeMessage,
buildTravelPlanningRecommendation,
buildTravelPlanningSuggestedActions
} from '../src/utils/travelApplicationPlanning.js'
import { renderMarkdown } from '../src/utils/markdown.js'
import {
createMessage as createConversationMessage,
@@ -142,6 +149,34 @@ test('application intent uses local preview instead of immediate orchestrator ca
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('travel application submit can continue with conversational planning recommendation', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海市',
reason: '支撑国网仿生产环境建设',
days: '4天',
transportMode: '火车'
}
})
const draftPayload = { claim_no: 'AP-202606030001-ABCDE123' }
const nudge = buildTravelPlanningNudgeMessage(preview, draftPayload)
const actions = buildTravelPlanningSuggestedActions(preview, draftPayload)
const recommendation = buildTravelPlanningRecommendation(preview, draftPayload)
assert.match(nudge, /上海市差旅申请已经提交/)
assert.match(nudge, /2026-02-20 至 2026-02-23/)
assert.deepEqual(actions.map((item) => item.action_type), [
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP
])
assert.match(recommendation, /轻量行程规划/)
assert.match(recommendation, /优先看上午到中午抵达 上海市 的火车班次/)
assert.match(recommendation, /客户现场周边/)
assert.match(recommendation, /AP-202606030001-ABCDE123/)
})
test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静',
@@ -767,3 +802,63 @@ test('application preview editor refreshes transport estimate after mode change'
assert.ok(persistCount >= 2)
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
})
test('application preview editor recalculates days and subsidy after date range change', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-05-25',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '1\u5929',
transportMode: '\u706b\u8f66',
amount: '',
grade: 'P5',
applicant: '\u674e\u6587\u9759',
department: '\u6280\u672f\u90e8',
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
managerName: '\u5411\u4e07\u7ea2'
}
})
const message = {
id: 'application-preview-editor-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5' }),
calculateTravelReimbursement: async (payload) => {
requestedPayloads.push(payload)
return {
days: payload.days,
location: payload.location,
matched_city: payload.location,
grade: payload.grade,
hotel_rate: 450,
hotel_amount: 1800,
total_allowance_rate: 100,
allowance_amount: 400,
total_amount: 2200,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.setApplicationPreviewDateMode('range')
editor.applicationPreviewEditor.value.rangeStartDate = '2026-02-20'
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-23'
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
})

View File

@@ -51,4 +51,10 @@ test('expense application submit uses rich text link and confirm dialog', () =>
createViewScript,
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
)
assert.match(createViewScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
assert.match(createViewScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
assert.match(createViewScript, /meta:\s*\['行程规划推荐'\]/)
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
assert.match(createViewScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_SKIP/)
})

View File

@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { departmentRangeOptions } from '../src/data/metrics.js'
const overviewView = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
)
const overviewViewModel = readFileSync(
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
'utf8'
)
const analyticsService = readFileSync(
fileURLToPath(new URL('../src/services/analytics.js', import.meta.url)),
'utf8'
)
const barChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)),
'utf8'
)
test('finance dashboard ranking range options support month quarter year and all', () => {
assert.deepEqual(departmentRangeOptions, ['本月', '本季度', '本年', '全部'])
assert.match(analyticsService, /department_employee_mix/)
assert.match(analyticsService, /departmentEmployeeMix/)
assert.match(analyticsService, /department_range/)
})
test('finance dashboard renders shared ranking filters and department employee mix chart', () => {
assert.match(overviewView, /<h3>部门报销排行/)
assert.match(overviewView, /aria-label="部门排行时间范围"/)
assert.match(overviewView, /<h3>个人报销排行/)
assert.match(overviewView, /aria-label="个人排行时间范围"/)
assert.doesNotMatch(overviewView, /个人报销排行(本月)/)
assert.match(overviewView, /<h3>高额单据/)
assert.doesNotMatch(overviewView, /本月高额单据/)
assert.match(overviewView, /class="top-claim-split"/)
assert.match(overviewView, /departmentEmployeeLegend/)
assert.match(overviewView, /departmentEmployeeCenterValue/)
assert.match(overviewViewModel, /financeDepartmentEmployeeMix/)
assert.match(overviewViewModel, /departmentEmployeeLegend/)
assert.match(overviewViewModel, /employeeCount/)
})
test('finance ranking bar chart can display ranking metadata', () => {
assert.match(barChart, /rank-meta/)
assert.match(barChart, /item\.meta/)
assert.match(overviewViewModel, /meta: `\$\{Number\(item\.employeeCount/)
assert.match(overviewViewModel, /meta: `\$\{item\.department/)
})

View File

@@ -121,8 +121,10 @@ test('workbench cards use layered glass material instead of texture-led cards',
assert.doesNotMatch(workbenchCardStyles, /background-blend-mode:\s*normal,\s*color,\s*normal;/)
assert.match(workbenchResponsiveStyles, /--workbench-glass-noise-opacity:\s*0\.008;/)
assert.match(workbenchResponsiveStyles, /--workbench-glass-blur:\s*blur\(14px\) saturate\(1\.2\);/)
assert.match(workbenchGlassStyles, /\.todo-row,[\s\S]*\.progress-row\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*inset 0 1px 0 rgba\(var\(--theme-primary-rgb/)
assert.doesNotMatch(workbenchGlassStyles, /\.todo-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
assert.match(workbenchGlassStyles, /\.progress-row\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*inset 0 1px 0 rgba\(var\(--theme-primary-rgb/)
assert.doesNotMatch(workbench, /<h2>我的待办<\/h2>/)
assert.doesNotMatch(workbench, /<h2>关键动作<\/h2>/)
assert.doesNotMatch(workbenchGlassStyles, /\.todo-row/)
assert.doesNotMatch(workbenchGlassStyles, /\.progress-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
assert.match(workbenchInsightStyles, /\.insight-metric-row,[\s\S]*\.insight-profile-card\s*\{[\s\S]*backdrop-filter:\s*blur\(10px\) saturate\(1\.16\)/)
assert.doesNotMatch(workbenchInsightStyles, /background:\s*#ffffff;/)

View File

@@ -82,3 +82,16 @@ test('risk dashboard wires window filter to trend, ranking, and cards data sourc
assert.match(dashboardComponent, /RiskDailyTrendChart/)
assert.match(dashboardComponent, /rankingGroups/)
})
test('risk dashboard shows loading overlay and realtime refresh status', () => {
assert.match(dashboardComponent, /risk-dashboard-loading-overlay/)
assert.match(dashboardComponent, /loadingLabel/)
assert.match(dashboardComponent, /lastUpdatedLabel/)
assert.match(dashboardComponent, /lastUpdatedAt/)
assert.match(overviewViewModel, /riskDashboardLastUpdatedAt/)
assert.match(overviewViewModel, /startRiskDashboardRealtimeRefresh/)
assert.match(overviewViewModel, /setInterval/)
assert.match(overviewViewModel, /document\.visibilityState === 'hidden'/)
assert.match(overviewViewModel, /riskDashboardRequestSeq/)
assert.match(overviewTemplate, /:last-updated-at="riskDashboardLastUpdatedAt"/)
})

View File

@@ -805,6 +805,29 @@ test('application detail uses application labels instead of reimbursement labels
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
})
test('returned application detail can open assistant with editable prefill', () => {
assert.match(
detailViewTemplate,
/v-if="canModifyReturnedApplication"[\s\S]*@click="handleModifyApplication"[\s\S]*修改申请/
)
assert.match(
detailViewScript,
/const canModifyReturnedApplication = computed\(\(\) => \([\s\S]*isApplicationDocument\.value[\s\S]*isCurrentApplicant\.value[\s\S]*returned/
)
assert.match(detailViewScript, /function buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationDetailFactItems\.value[\s\S]*sourceText:\s*'修改申请'/)
assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
assert.match(detailViewScript, /function handleModifyApplication\(\)/)
assert.match(detailViewScript, /source:\s*'application'/)
assert.match(detailViewScript, /sessionType:\s*'application'/)
assert.match(detailViewScript, /prompt:\s*''/)
assert.match(detailViewScript, /applicationPreview:\s*buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationEditMode:\s*true/)
assert.match(detailViewScript, /initialPromptAutoSubmit:\s*false/)
assert.match(detailViewScript, /canModifyReturnedApplication,/)
assert.match(detailViewScript, /handleModifyApplication,/)
})
test('application detail does not show optional travel receipt reminders', () => {
const request = {
documentTypeCode: 'application',
@@ -943,6 +966,34 @@ test('transport ticket items no longer generate business location completion adv
assert.doesNotMatch(detailViewScript, /完善第 1 条费用明细的业务地点/)
})
test('compliant attachment analysis does not create medium risk cards', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'item-001',
invoiceId: 'mock/invoice-001.txt',
itemReason: 'taxi',
itemType: 'transport'
}
],
attachmentMetaByItemId: {
'item-001': {
analysis: {
severity: 'success',
label: 'compliant',
headline: 'invoice fields match reimbursement item',
summary: 'mock OCR fields are consistent with the reimbursement detail',
points: ['amount and document type are consistent']
}
}
}
})
assert.deepEqual(riskCards, [])
assert.match(detailViewInsights, /success', 'ok', 'normal', 'none', 'compliant', 'approved'/)
assert.match(detailViewScript, /tone: normalizeRiskTone\(analysis\.severity \|\| 'low'\)/)
})
test('return reason dialog is wired into approval and detail return actions', () => {
assert.match(returnReasonDialog, /missing_attachment/)
assert.match(returnReasonDialog, /invoice_mismatch/)

View File

@@ -94,8 +94,9 @@ test('archived detail delete action is gated by admin-only permission', () => {
test('editable detail delete action is limited to applicant or claim manager', () => {
assert.match(detailViewScript, /const isCurrentApplicant = computed/)
assert.match(detailViewScript, /isPlatformAdminUser/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\)\s*}/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\) \|\| \(isEditableRequest\.value && isCurrentApplicant\.value\)\s*}/)
assert.match(detailViewScript, /if \(canManageCurrentClaim\.value\) {\s*return true\s*}/)
assert.match(detailViewScript, /return isEditableRequest\.value && isCurrentApplicant\.value/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
})

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAdjacentProgressSteps,
buildWorkbenchSummary
} from '../src/utils/workbenchSummary.js'
const currentUser = { name: '张三', username: 'zhangsan' }
function buildStep(label, index, currentIndex) {
return {
label,
done: index < currentIndex,
active: index <= currentIndex,
current: index === currentIndex,
time: index === currentIndex ? '进行中' : '待处理'
}
}
test('workbench expense progress keeps only nearby four expense steps', () => {
const steps = ['创建单据', '待提交', '直属领导审批', '财务审批', '待付款', '归档入账']
.map((label, index) => buildStep(label, index, 3))
const visibleSteps = buildAdjacentProgressSteps(steps, 4)
assert.deepEqual(
visibleSteps.map((step) => step.label),
['直属领导审批', '财务审批', '待付款', '归档入账']
)
assert.equal(visibleSteps[1].current, true)
})
test('workbench summary builds real user notifications and progress from requests', () => {
const summary = buildWorkbenchSummary(
[
{
id: 'BX-001',
claimId: 'claim-1',
claimNo: 'BX-001',
person: '张三',
title: '差旅报销',
approvalKey: 'draft',
approvalStatus: '草稿',
status: 'draft',
amount: 1280,
createdAt: '2026-06-01T10:00:00+08:00',
updatedAt: '2026-06-01T10:10:00+08:00',
progressSteps: [
buildStep('创建单据', 0, 1),
buildStep('待提交', 1, 1),
buildStep('直属领导审批', 2, 1),
buildStep('财务审批', 3, 1),
buildStep('待付款', 4, 1)
]
},
{
id: 'BX-002',
claimId: 'claim-2',
claimNo: 'BX-002',
person: '李四',
title: '他人单据',
approvalKey: 'draft',
amount: 800,
progressSteps: []
}
],
currentUser
)
assert.equal(summary.todoItems.length, 1)
assert.equal(summary.todoItems[0].target.id, 'claim-1')
assert.equal(summary.progressItems.length, 1)
assert.deepEqual(
summary.progressItems[0].steps.map((step) => step.label),
['创建单据', '待提交', '直属领导审批', '财务审批']
)
assert.equal(summary.notifications.length, 1)
assert.equal(summary.unreadNotificationCount, 1)
})

View File

@@ -817,13 +817,15 @@ async function startBackendAndWait() {
return cloneBackendStartState()
}
function localSetupPlugin() {
return {
name: 'local-setup-api',
configureServer(server) {
server.watcher.unwatch(envFile)
server.watcher.unwatch(envExampleFile)
server.watcher.unwatch(path.join(rootDir, 'server', 'logs'))
function localSetupPlugin() {
return {
name: 'local-setup-api',
configureServer(server) {
server.watcher.unwatch(envFile)
server.watcher.unwatch(envExampleFile)
server.watcher.unwatch(path.join(rootDir, 'server', 'logs'))
server.watcher.unwatch(path.join(rootDir, 'server', 'storage'))
server.watcher.unwatch(path.join(rootDir, 'test-results'))
server.middlewares.use('/__setup/auth/login', async (req, res) => {
try {
@@ -1030,15 +1032,18 @@ export default defineConfig({
? {
// Docker bind mounts can miss fs events for Vue SFCs, which leaves Vite serving stale templates.
usePolling: true,
interval: 250
interval: 1000
}
: {}),
ignored: [
envFile,
envExampleFile,
path.join(rootDir, 'server', 'logs', '**')
]
},
path.join(rootDir, 'server', 'logs', '**'),
path.join(rootDir, 'server', 'storage', '**'),
path.join(rootDir, 'test-results', '**'),
path.join(rootDir, '.codex-remote-attachments', '**')
]
},
proxy: {
'/api': {
target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`,