feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

View File

@@ -1,107 +1,142 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%">
<defs>
<!-- Soft, large shadow for the main cards -->
<filter id="shadow-lg" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#0f172a" flood-opacity="0.08"/>
<!-- Background glowing orbs -->
<filter id="blur-huge" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="60"/>
</filter>
<!-- Tighter shadow for floating chips -->
<filter id="shadow-sm" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#3a7ca5" flood-opacity="0.12"/>
<!-- Premium drop shadows -->
<filter id="glass-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#020617" flood-opacity="0.12"/>
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#020617" flood-opacity="0.08"/>
</filter>
<!-- Glowing effect for spheres -->
<filter id="glow">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
<filter id="glow-cyan" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="12" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
<linearGradient id="glass-base" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#f8fafc" stop-opacity="0.65"/>
<!-- Apple Liquid Glass Gradients -->
<linearGradient id="glass-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.85"/>
<stop offset="40%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.25"/>
</linearGradient>
<linearGradient id="blue-primary" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a7ca5" stop-opacity="1"/>
<stop offset="100%" stop-color="#255b7d" stop-opacity="1"/>
<linearGradient id="glass-border" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.1"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="amber-accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b58b4c" stop-opacity="1"/>
<stop offset="100%" stop-color="#d4a359" stop-opacity="1"/>
<!-- Vibrant Data Gradients -->
<linearGradient id="grad-blue" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="1"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="1"/>
</linearGradient>
<linearGradient id="grad-amber" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f59e0b" stop-opacity="1"/>
<stop offset="100%" stop-color="#ea580c" stop-opacity="1"/>
</linearGradient>
<linearGradient id="grad-emerald" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#10b981" stop-opacity="1"/>
<stop offset="100%" stop-color="#059669" stop-opacity="1"/>
</linearGradient>
<linearGradient id="area-blue" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.0"/>
</linearGradient>
</defs>
<!-- Ambient Glowing Orbs Background removed as per user request -->
<g transform="translate(40, 20)">
<!-- Background Document (Left) -->
<g transform="translate(20, 60) rotate(-12)" filter="url(#shadow-lg)">
<rect width="260" height="340" rx="16" fill="url(#glass-base)" stroke="#ffffff" stroke-width="2"/>
<!-- UI Skeleton Lines -->
<text x="30" y="50" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="bold" fill="#64748b">支出分析</text>
<rect x="30" y="70" width="200" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
<rect x="30" y="90" width="160" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
<!-- ========================================== -->
<!-- 1. Expense Analysis Document (Left, Back) -->
<!-- ========================================== -->
<g transform="translate(60, 30) rotate(-10)" filter="url(#glass-shadow)">
<!-- Liquid Glass Base -->
<rect width="280" height="340" rx="24" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
<rect width="280" height="340" rx="24" fill="none" stroke="#ffffff" stroke-width="4" stroke-opacity="0.4" style="mix-blend-mode: overlay;"/>
<!-- Bar Chart Component -->
<rect x="30" y="140" width="24" height="80" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="70" y="100" width="24" height="120" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="110" y="160" width="24" height="60" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="150" y="80" width="24" height="140" rx="6" fill="url(#blue-primary)"/>
<rect x="190" y="120" width="24" height="100" rx="6" fill="#e2e8f0" opacity="0.8"/>
<!-- Header -->
<circle cx="40" cy="46" r="12" fill="url(#grad-blue)"/>
<text x="66" y="52" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="900" fill="#1e293b" letter-spacing="1">支出分析</text>
<!-- Floating Glass Donut Chart -->
<g transform="translate(140, 150)">
<circle cx="0" cy="0" r="50" fill="none" stroke="#e2e8f0" stroke-width="16" opacity="0.6"/>
<circle cx="0" cy="0" r="50" fill="none" stroke="url(#grad-blue)" stroke-width="16" stroke-dasharray="200 314" stroke-linecap="round" filter="url(#glow-cyan)"/>
<circle cx="0" cy="0" r="50" fill="none" stroke="url(#grad-amber)" stroke-width="16" stroke-dasharray="60 314" stroke-dashoffset="-220" stroke-linecap="round"/>
<text x="0" y="8" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="900" fill="#1e293b" text-anchor="middle">72%</text>
</g>
<!-- Data Bars -->
<rect x="30" y="240" width="220" height="40" rx="12" fill="#ffffff" opacity="0.6"/>
<rect x="42" y="256" width="120" height="8" rx="4" fill="url(#grad-blue)"/>
<rect x="180" y="256" width="50" height="8" rx="4" fill="#94a3b8"/>
<rect x="30" y="290" width="220" height="40" rx="12" fill="#ffffff" opacity="0.6"/>
<rect x="42" y="306" width="80" height="8" rx="4" fill="url(#grad-amber)"/>
<rect x="140" y="306" width="90" height="8" rx="4" fill="#94a3b8"/>
</g>
<!-- Floating Element 1 (Left) -->
<g transform="translate(40, 20) rotate(12)" filter="url(#glass-shadow)">
<rect width="80" height="80" rx="20" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
<path d="M 25 40 L 35 50 L 55 30" fill="none" stroke="url(#grad-emerald)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<!-- Center Floating Sphere -->
<circle cx="280" cy="90" r="30" fill="url(#amber-accent)" opacity="0.85" filter="url(#glow)"/>
<circle cx="340" cy="110" r="30" fill="url(#grad-amber)" opacity="0.9" filter="url(#glass-shadow)"/>
<!-- Main Foreground Document (Right Focus) -->
<g transform="translate(320, 10) rotate(6)" filter="url(#shadow-lg)">
<!-- Main Card Body -->
<rect width="320" height="400" rx="20" fill="url(#glass-base)" stroke="#ffffff" stroke-width="3"/>
<!-- ========================================== -->
<!-- 2. Cost Trend Document (Right, Front) -->
<!-- ========================================== -->
<g transform="translate(320, 10) rotate(5)" filter="url(#glass-shadow)">
<!-- Liquid Glass Base -->
<rect width="320" height="380" rx="24" fill="url(#glass-bg)" stroke="url(#glass-border)" stroke-width="1.5"/>
<rect width="320" height="380" rx="24" fill="none" stroke="#ffffff" stroke-width="4" stroke-opacity="0.5" style="mix-blend-mode: overlay;"/>
<!-- Header Section -->
<circle cx="44" cy="50" r="18" fill="url(#blue-primary)"/>
<text x="76" y="58" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="800" fill="#3a7ca5">费用趋势</text>
<circle cx="44" cy="50" r="14" fill="url(#grad-blue)"/>
<text x="74" y="57" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="900" fill="#1e293b" letter-spacing="1">费用趋势</text>
<!-- Area Chart Widget -->
<rect x="30" y="100" width="260" height="130" rx="12" fill="#f1f5f9" opacity="0.5"/>
<path d="M 30 200 L 40 180 Q 90 120 140 150 T 260 90 L 290 90 L 290 230 L 30 230 Z" fill="#3a7ca5" opacity="0.1"/>
<path d="M 40 180 Q 90 120 140 150 T 260 90" fill="none" stroke="#3a7ca5" stroke-width="4" stroke-linecap="round"/>
<!-- Chart Nodes -->
<circle cx="140" cy="150" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
<circle cx="260" cy="90" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
<!-- Data List Rows -->
<g transform="translate(30, 250)" filter="url(#shadow-sm)">
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
<rect x="16" y="16" width="60" height="8" rx="4" fill="#94a3b8"/>
<rect x="200" y="16" width="44" height="8" rx="4" fill="url(#blue-primary)"/>
</g>
<g transform="translate(30, 306)" filter="url(#shadow-sm)">
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
<rect x="16" y="16" width="80" height="8" rx="4" fill="#cbd5e1"/>
<rect x="210" y="16" width="34" height="8" rx="4" fill="url(#blue-primary)"/>
<!-- Beautiful Smooth Area Chart -->
<g transform="translate(30, 100)">
<rect width="260" height="140" rx="16" fill="#ffffff" opacity="0.5"/>
<!-- Grid lines -->
<line x1="20" y1="35" x2="240" y2="35" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
<line x1="20" y1="70" x2="240" y2="70" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
<line x1="20" y1="105" x2="240" y2="105" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 4"/>
<!-- Smooth Area -->
<path d="M 20 120 C 60 120, 80 40, 130 60 C 180 80, 200 20, 240 30 L 240 140 L 20 140 Z" fill="url(#area-blue)"/>
<!-- Smooth Line -->
<path d="M 20 120 C 60 120, 80 40, 130 60 C 180 80, 200 20, 240 30" fill="none" stroke="url(#grad-blue)" stroke-width="4" stroke-linecap="round"/>
<!-- Glowing Data Point -->
<circle cx="240" cy="30" r="6" fill="#ffffff" stroke="url(#grad-blue)" stroke-width="3" filter="url(#glow-cyan)"/>
<!-- Value Tooltip -->
<rect x="190" y="-10" width="60" height="24" rx="12" fill="#1e293b"/>
<text x="220" y="6" font-family="sans-serif" font-size="12" font-weight="bold" fill="#ffffff" text-anchor="middle">+14%</text>
</g>
<!-- List Items -->
<rect x="30" y="260" width="260" height="44" rx="12" fill="#ffffff" opacity="0.8"/>
<circle cx="50" cy="282" r="8" fill="url(#grad-emerald)"/>
<rect x="74" y="278" width="100" height="8" rx="4" fill="#64748b"/>
<rect x="230" y="278" width="40" height="8" rx="4" fill="url(#grad-blue)"/>
<rect x="30" y="316" width="260" height="44" rx="12" fill="#ffffff" opacity="0.8"/>
<circle cx="50" cy="338" r="8" fill="url(#grad-amber)"/>
<rect x="74" y="334" width="80" height="8" rx="4" fill="#64748b"/>
<rect x="240" y="334" width="30" height="8" rx="4" fill="url(#grad-amber)"/>
</g>
<!-- Floating UI Chip 1 (Top Left) -->
<g transform="translate(140, 0) rotate(-6)" filter="url(#shadow-sm)">
<rect width="150" height="48" rx="24" fill="#ffffff" stroke="#f1f5f9" stroke-width="2"/>
<circle cx="24" cy="24" r="10" fill="#10b981"/>
<rect x="44" y="20" width="80" height="8" rx="4" fill="#334155"/>
</g>
<!-- Floating UI Chip 2 (Bottom Right) -->
<g transform="translate(580, 280) rotate(14)" filter="url(#shadow-sm)">
<rect width="130" height="52" rx="14" fill="url(#blue-primary)"/>
<rect x="20" y="22" width="90" height="8" rx="4" fill="#ffffff" opacity="0.9"/>
</g>
<!-- Decorative small spheres -->
<circle cx="120" cy="350" r="8" fill="#3a7ca5" opacity="0.4" filter="url(#glow)"/>
<circle cx="680" cy="120" r="12" fill="#b58b4c" opacity="0.3" filter="url(#glow)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

View File

@@ -0,0 +1,271 @@
/* Current DigitalEmployeeWorkRecords scoped styles. Kept last to override retired rules above. */
.digital-work-records {
height: 100%;
}
.digital-employee-list-panel,
.digital-work-records-list-stage {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.digital-work-records-table {
min-width: 1180px;
table-layout: fixed;
}
.digital-work-records-table .col-time { width: 14%; }
.digital-work-records-table .col-module { width: 13%; }
.digital-work-records-table .col-source { width: 10%; }
.digital-work-records-table .col-status { width: 16%; }
.digital-work-records-table .col-summary { width: 31%; }
.digital-work-records-table .col-trace { width: 16%; }
.work-record-row {
outline: none;
}
.work-record-row:focus-visible {
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
}
.work-record-summary-cell {
text-align: left !important;
}
.work-record-summary-cell strong,
.work-record-summary-cell span,
.work-record-summary-cell em {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-record-summary-cell strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.work-record-summary-cell span {
margin-top: 4px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.work-record-summary-cell em {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
font-style: normal;
}
.work-record-trace-cell {
color: #2563eb !important;
}
:deep(.work-record-detail-page) {
flex: 1 1 0;
min-height: 0;
padding: 16px 18px 0;
background: transparent;
}
:deep(.work-record-detail-page .detail-scroll) {
min-height: 0;
display: block;
padding-right: 4px;
overflow: auto;
}
:deep(.work-record-detail-page .detail-scroll) > * + * {
margin-top: 14px;
}
:deep(.work-record-detail-page .detail-grid) {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
align-items: start;
width: min(100%, 1160px);
margin: 0 auto;
}
:deep(.work-record-detail-page .detail-main),
:deep(.work-record-detail-page .detail-side) {
display: grid;
align-content: start;
gap: 14px;
min-height: 0;
min-width: 0;
}
:deep(.work-record-detail-page .detail-actions) {
margin-top: 10px;
padding: 10px 0 0;
}
:deep(.work-record-detail-page .enterprise-detail-card) {
min-height: 0;
padding: 14px;
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
box-shadow: none;
}
:deep(.work-record-detail-page .enterprise-detail-card .card-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
:deep(.work-record-detail-page .enterprise-detail-card .card-head h3) {
margin: 0;
color: #111827;
font-size: 15px;
line-height: 1.35;
font-weight: 850;
}
:deep(.work-record-detail-page .enterprise-detail-card .card-head p) {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
:deep(.work-record-detail-page .edit-badge) {
min-height: 26px;
padding: 0 9px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
}
:deep(.work-record-summary-card .json-risk-meta-grid) {
padding: 0;
}
:deep(.work-record-detail-page .json-risk-meta-item),
:deep(.work-record-detail-page .json-risk-description-text) {
border-radius: 4px;
}
.work-record-summary-text {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.7;
}
.work-record-error-text {
margin: 12px 0 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 4px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
line-height: 1.6;
}
.work-record-tool-list {
display: grid;
gap: 8px;
}
.work-record-tool-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #f8fafc;
transition: border-color 160ms ease, background 160ms ease;
}
.work-record-tool-item:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26);
background: #f9fbff;
}
.work-record-tool-item strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-record-tool-item span {
color: #64748b;
font-size: 12px;
white-space: nowrap;
}
.work-record-inline-empty {
min-height: 92px;
display: grid;
place-items: center;
padding: 14px;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
color: #94a3b8;
font-size: 13px;
line-height: 1.6;
text-align: center;
}
.work-record-code-block {
max-height: 360px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #111827;
color: #e5e7eb;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
}
.digital-work-records :deep(.toolbar-actions .picker-filter),
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
min-width: 148px;
}
.digital-refresh-now {
width: 40px;
min-width: 40px;
padding: 0;
}
.digital-refresh-now .mdi {
font-size: 18px;
}
@media (max-width: 1180px) {
.work-record-tool-item {
grid-template-columns: 1fr;
}
}

View File

@@ -137,6 +137,171 @@
color: var(--theme-primary-active);
}
.document-filter,
.date-range-filter {
position: relative;
}
.document-filter-menu,
.date-range-popover {
position: absolute;
z-index: 40;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow: hidden;
}
.document-filter-menu {
top: calc(100% + 8px);
left: 0;
min-width: 150px;
max-height: 280px;
padding: 6px;
overflow-y: auto;
}
.document-filter-menu button {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
}
.document-filter-menu button:hover,
.document-filter-menu button.active {
background: rgba(58, 124, 165, 0.1);
color: var(--theme-primary-active);
}
.date-range-trigger {
min-width: 150px;
}
.date-range-label {
max-width: 104px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-popover {
top: calc(100% + 8px);
left: 0;
width: 320px;
display: grid;
gap: 14px;
padding: 16px;
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #64748b;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 4px;
color: #0f172a;
font-size: 13px;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 4px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: var(--theme-primary);
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.document-status-filter {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 38px;
}
.status-dropdown-filter,
.status-filter-trigger,
.status-filter-menu {
min-width: 154px;
}
.status-filter-trigger > .mdi:first-child {
color: var(--theme-primary);
}
.clear-filter-btn {
justify-content: center;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;

View File

@@ -61,7 +61,7 @@
position: absolute;
top: calc(100% + 8px);
left: 18px;
z-index: 60;
z-index: 120;
width: min(320px, calc(100% - 36px));
max-width: calc(100vw - 32px);
display: grid;

View File

@@ -14,38 +14,22 @@
}
.capability-card {
position: relative;
isolation: isolate;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 42%, rgba(255, 255, 255, 0.72));
background:
var(--workbench-glass-base),
linear-gradient(135deg, color-mix(in srgb, var(--capability-soft) 46%, transparent) 0%, transparent 52%, color-mix(in srgb, var(--capability-color) 11%, transparent) 100%),
var(--workbench-glass-theme-tint);
background-color: rgba(255, 255, 255, 0.64);
box-shadow:
0 10px 28px rgba(15, 23, 42, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.84),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
backdrop-filter: var(--workbench-glass-blur);
-webkit-backdrop-filter: var(--workbench-glass-blur);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 60%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.8));
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.96);
}
.capability-card::before,
.capability-card::after,
.workbench-card::before,
.workbench-card::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
display: block;
pointer-events: none;
}
.capability-card::before {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 38%),
var(--workbench-capability-bg-image) 0 0 / var(--workbench-capability-tile-size) repeat;
mix-blend-mode: soft-light;
opacity: var(--workbench-glass-noise-opacity);
}
@@ -77,40 +61,18 @@
.workbench-card {
position: relative;
isolation: isolate;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.64) 55%, rgba(255, 255, 255, 0.72)),
var(--workbench-glass-theme-tint);
background-color: rgba(255, 255, 255, 0.66);
box-shadow:
0 12px 30px rgba(15, 23, 42, 0.052),
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
backdrop-filter: var(--workbench-glass-blur);
-webkit-backdrop-filter: var(--workbench-glass-blur);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.96);
}
.workbench-card::before,
.workbench-card::after {
border-radius: inherit;
}
.workbench-card::before {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 42%),
var(--workbench-panel-bg-image) 0 0 / var(--workbench-panel-tile-size) repeat;
mix-blend-mode: soft-light;
opacity: calc(var(--workbench-glass-noise-opacity) * 0.8);
}
.workbench-card::after {
border: 1px solid rgba(255, 255, 255, 0.36);
background: var(--workbench-glass-highlight);
opacity: 0.56;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.58),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.055);
transition: opacity 180ms var(--ease);
display: none !important;
}
.workbench-card > * {
@@ -138,15 +100,10 @@
.capability-card:hover,
.workbench-card:hover {
box-shadow:
0 16px 36px rgba(15, 23, 42, 0.075),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
}
.capability-card:hover::after,
.workbench-card:hover::after {
opacity: 0.88;
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4);
box-shadow:
0 16px 36px rgba(15, 23, 42, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 1);
}
.capability-card:hover {

View File

@@ -66,10 +66,12 @@
.insight-metric-list,
.insight-profile-list {
min-height: 0;
display: grid;
gap: 6px;
grid-auto-rows: minmax(0, 1fr);
display: flex;
flex-direction: column;
justify-content: space-evenly;
gap: 0;
overflow: hidden;
height: 100%;
}
.insight-metric-row,
@@ -94,6 +96,15 @@
transition:
border-color 180ms var(--ease),
background-color 180ms var(--ease);
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.insight-metric-row {
animation-delay: calc(400ms + var(--item-index, 0) * 80ms);
}
.insight-profile-card {
animation-delay: calc(500ms + var(--item-index, 0) * 80ms);
}
.insight-metric-row:hover,
@@ -104,22 +115,6 @@
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
}
/* 局部改造让费用统计内层的小卡片也变为低透明度透镜形成双层液态玻璃Double Glassmorphism的极品手感 */
.expense-stats-panel .insight-metric-row {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
border-left-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.03),
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important;
}
.expense-stats-panel .insight-metric-row:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
}
.insight-metric-icon,
.insight-profile-icon {

View File

@@ -4,8 +4,8 @@
--hero-padding-top: 20px;
--hero-padding-bottom: 20px;
--hero-title-size: 28px;
--hero-copy-gap: 5px;
--hero-title-bottom-gap: 14px;
--hero-copy-gap: 16px;
--hero-title-bottom-gap: 10px;
--composer-min-height: 108px;
--composer-textarea-height: 48px;
--composer-padding-block: 10px;
@@ -15,7 +15,9 @@
}
.assistant-hero {
--assistant-bg-position: 56% center;
--assistant-bg-position: right center;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.86;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
}
@@ -58,7 +60,9 @@
}
.assistant-hero {
--assistant-bg-position: 58% center;
--assistant-bg-position: right center;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.9;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
}
@@ -83,7 +87,7 @@
}
.capability-copy {
padding-left: 14px;
padding-left: 0;
}
.workbench-content-grid {
@@ -110,13 +114,15 @@
}
.assistant-hero {
--assistant-bg-position: 62% center;
--assistant-bg-position: right center;
--assistant-decor-width: clamp(620px, 74vw, 860px);
--assistant-decor-opacity: 0.62;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 58%, rgba(255, 255, 255, 0.44) 100%);
linear-gradient(90deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.5) 58%, rgba(255, 255, 255, 0.06) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14) 100%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09) 100%);
backdrop-filter: blur(10px) saturate(1.12);
-webkit-backdrop-filter: blur(10px) saturate(1.12);
}
.assistant-copy {
@@ -140,6 +146,125 @@
}
}
@media (min-width: 961px) and (max-width: 1440px),
(min-width: 961px) and (max-height: 820px) {
.workbench {
--hero-padding-top: 14px;
--hero-padding-bottom: 14px;
--hero-title-size: 24px;
--hero-copy-gap: 14px;
--hero-title-bottom-gap: 8px;
--composer-min-height: 92px;
--composer-textarea-height: 38px;
--composer-padding-block: 8px;
--quick-prompts-gap-top: 5px;
--capability-row-height: 82px;
gap: 8px;
}
.assistant-hero {
--assistant-decor-width: clamp(680px, 60vw, 880px);
--assistant-decor-opacity: 0.72;
padding: var(--hero-padding-top) 16px var(--hero-padding-bottom) 34px;
}
.assistant-copy {
width: min(900px, 92%);
}
.assistant-copy h1 {
margin-bottom: var(--hero-title-bottom-gap);
font-size: var(--hero-title-size);
line-height: 1.14;
}
.assistant-composer {
min-height: var(--composer-min-height);
gap: 4px;
padding: var(--composer-padding-block) 14px 8px;
}
.assistant-composer textarea {
height: var(--composer-textarea-height);
min-height: var(--composer-textarea-height);
max-height: var(--composer-textarea-height);
font-size: 14px;
line-height: 1.42;
}
.composer-toolbar {
gap: 8px;
}
.composer-icon-button,
.composer-send-button {
height: 30px;
}
.composer-icon-button {
width: 30px;
font-size: 17px;
}
.composer-send-button {
width: 46px;
font-size: 16px;
}
.composer-count {
font-size: 12px;
}
.quick-prompts {
gap: 8px;
margin-top: var(--quick-prompts-gap-top);
font-size: 12.5px;
}
.quick-prompts button {
min-height: 24px;
padding: 0 10px;
font-size: 12px;
}
.capability-grid {
gap: 10px;
}
.capability-card {
grid-template-columns: 34px minmax(0, 1fr) 14px;
gap: 10px;
padding: 12px 12px 12px 16px;
}
.capability-icon {
--workbench-list-icon-size: 34px;
--workbench-list-icon-art-size: 20px;
width: 34px;
height: 34px;
}
.capability-copy {
gap: 2px;
}
.capability-copy strong {
font-size: 13px;
line-height: 1.2;
}
.capability-copy small {
font-size: 11px;
line-height: 1.22;
}
.capability-arrow {
width: 14px;
min-width: 14px;
font-size: 16px;
}
}
@media (max-width: 760px) {
.workbench {
height: auto;
@@ -156,15 +281,17 @@
.assistant-hero {
min-height: auto;
--assistant-bg-position: 68% center;
--assistant-bg-position: right center;
--assistant-decor-width: min(620px, 118vw);
--assistant-decor-opacity: 0.36;
--assistant-readability-mask:
linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.88) 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.72) 100%);
linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.76) 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.52) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08) 100%);
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 100%);
padding: 24px 18px 24px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(9px) saturate(1.1);
-webkit-backdrop-filter: blur(9px) saturate(1.1);
}
.assistant-copy {
@@ -311,7 +438,7 @@
}
.capability-copy {
padding-left: 6px;
padding-left: 0;
gap: 2px;
}

View File

@@ -57,31 +57,48 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero {
--assistant-bg-position: right center;
--assistant-decor-width: clamp(860px, 62vw, 1180px);
--assistant-decor-opacity: 0.92;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.34) 46%, rgba(255, 255, 255, 0) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025) 54%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075));
position: relative;
z-index: 2;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
overflow: visible;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background-color: transparent;
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.04), inset 0 2px 4px rgba(255, 255, 255, 1);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.54)),
var(--assistant-theme-tint);
background-color: rgba(247, 252, 255, 0.72);
backdrop-filter: blur(14px) saturate(1.18);
-webkit-backdrop-filter: blur(14px) saturate(1.18);
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.045),
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
isolation: isolate;
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: 0ms;
}
.assistant-hero::after {
content: "";
position: absolute;
top: 0;
right: 100px;
right: 0;
bottom: 0;
width: 50%;
min-width: 400px;
background: url("../../images/hero-financial-decor.svg") right center / auto 100% no-repeat;
width: 82%;
min-width: 760px;
background: url("../../images/workbench-hero-right-bg.png") var(--assistant-bg-position) / var(--assistant-decor-width) auto no-repeat;
opacity: var(--assistant-decor-opacity);
pointer-events: none;
z-index: 0;
}
@@ -92,8 +109,9 @@
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
var(--assistant-readability-mask),
linear-gradient(120deg, rgba(255, 255, 255, 0.36), transparent 22%, transparent 72%, rgba(255, 255, 255, 0.18)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.05), transparent 58%);
pointer-events: none;
z-index: 1;
}
@@ -114,8 +132,23 @@
font-weight: 850;
}
.assistant-copy h1 span {
.assistant-copy h1 span:not(.typing-cursor) {
color: var(--workbench-primary-active);
display: inline-block;
animation: workbenchItemIn 400ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.typing-cursor {
display: inline-block;
color: var(--workbench-primary-active);
font-weight: 400;
margin-left: 2px;
animation: cursorBlink 0.9s step-end infinite;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.assistant-copy p {
@@ -127,29 +160,70 @@
font-weight: 600;
}
.assistant-copy > * {
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.assistant-copy > h1 { animation-delay: 80ms; }
.assistant-copy > p { animation-delay: 160ms; }
.assistant-copy > .assistant-composer { animation-delay: 240ms; }
.assistant-copy > .assistant-file-strip { animation-delay: 320ms; }
.assistant-copy > .quick-prompts { animation-delay: 320ms; }
.assistant-file-input { display: none; }
.assistant-composer {
position: relative;
z-index: 5;
z-index: 20;
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);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
border-radius: 4px;
background: rgba(255, 255, 255, 0.96);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96);
backdrop-filter: blur(4px);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.74)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.045), rgba(255, 255, 255, 0.18));
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.045),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06);
backdrop-filter: blur(10px) saturate(1.14);
-webkit-backdrop-filter: blur(10px) saturate(1.14);
transition:
border-color 180ms var(--ease),
background 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.assistant-composer::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background:
linear-gradient(110deg, rgba(255, 255, 255, 0.32), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 42%);
pointer-events: none;
}
.assistant-composer > * {
position: relative;
z-index: 1;
}
.assistant-composer:focus-within {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.85);
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.58);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06), rgba(255, 255, 255, 0.22));
box-shadow:
0 0 0 4px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14),
0 16px 36px rgba(15, 23, 42, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.96);
0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11),
0 14px 30px rgba(15, 23, 42, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.94),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
}
.assistant-composer textarea {
@@ -331,24 +405,28 @@
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 10px;
grid-template-columns: 40px minmax(0, 1fr) 18px;
align-items: center;
gap: 14px;
min-height: 0;
padding: 17px 12px 17px 26px;
padding: 16px 18px 16px 22px;
overflow: visible;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.9);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 80%, rgba(255, 255, 255, 0.9));
border-radius: 12px;
background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background-color: transparent;
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
text-align: left;
min-width: 0;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15);
border-radius: 4px;
box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04),
inset 0 2px 4px rgba(255, 255, 255, 1);
0 8px 24px rgba(15, 23, 42, 0.03),
inset 0 1px 0 rgba(255, 255, 255, 1);
color: var(--workbench-ink);
text-decoration: none;
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: var(--delay, 100ms);
transition:
border-color 180ms var(--ease),
box-shadow 180ms var(--ease),
@@ -356,16 +434,12 @@
transform 180ms var(--ease);
}
.capability-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 0;
.capability-card:hover {
transform: translateY(-2px);
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.4);
box-shadow:
0 16px 32px rgba(15, 23, 42, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 1);
}
.capability-card > * {
@@ -373,13 +447,9 @@
z-index: 1;
}
.capability-card::after {
display: none;
}
.capability-icon {
--workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 23px;
--workbench-list-icon-art-size: 24px;
width: 40px;
height: 40px;
color: var(--capability-color);
@@ -388,8 +458,9 @@
.capability-copy {
min-width: 0;
display: grid;
justify-items: start;
gap: 4px;
padding-left: 18px;
text-align: left;
}
.capability-copy strong {
@@ -400,6 +471,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.capability-copy small {
@@ -409,11 +481,19 @@
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.capability-arrow {
justify-self: end;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
min-width: 18px;
color: color-mix(in srgb, var(--workbench-muted) 68%, #ffffff);
font-size: 18px;
line-height: 1;
}
.capability-card--green {
@@ -461,28 +541,16 @@
min-height: 0;
height: 100%;
padding: 12px 14px;
background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background-color: transparent;
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.15);
border-radius: 4px;
box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04),
inset 0 2px 4px rgba(255, 255, 255, 1);
}
.workbench-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 1;
0 12px 28px rgba(15, 23, 42, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 1);
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: var(--delay, 200ms);
}
.workbench-card > * {
@@ -534,6 +602,18 @@
font-weight: 850;
}
.insight-metric-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
border-radius: 4px;
background: color-mix(in srgb, var(--insight-color) 4%, transparent);
transition: transform 180ms var(--ease), background-color 180ms var(--ease);
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: calc(400ms + var(--item-index, 0) * 80ms);
}
.link-action {
display: inline-flex;
align-items: center;
@@ -560,6 +640,11 @@
border-top: 0;
}
.progress-row {
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
}
.progress-identity,
.progress-result {
gap: 12px;
@@ -716,40 +801,66 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 20px;
width: 42px;
height: 42px;
border-radius: 12px;
font-size: 22px;
flex-shrink: 0;
position: relative;
box-shadow:
0 4px 10px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(0, 0, 0, 0.03);
}
.expense-type-icon::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%);
border-radius: inherit;
z-index: 0;
}
.expense-type-icon i {
position: relative;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.12));
}
.expense-type-icon--blue {
background: color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 3%, #ffffff) 100%);
border: 1px solid color-mix(in srgb, var(--workbench-primary, #3a7ca5) 20%, #ffffff);
color: var(--workbench-primary, #3a7ca5);
}
.expense-type-icon--amber {
background: color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 3%, #ffffff) 100%);
border: 1px solid color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 20%, #ffffff);
color: var(--workbench-chart-amber, #b58b4c);
}
.expense-type-icon--emerald {
background: color-mix(in srgb, #10b981 12%, #ffffff);
color: #10b981;
background: linear-gradient(135deg, color-mix(in srgb, #0f8f68 12%, #ffffff) 0%, color-mix(in srgb, #0f8f68 3%, #ffffff) 100%);
border: 1px solid color-mix(in srgb, #0f8f68 20%, #ffffff);
color: #0f8f68;
}
.expense-type-icon--violet {
background: color-mix(in srgb, #8b5cf6 12%, #ffffff);
color: #8b5cf6;
background: linear-gradient(135deg, color-mix(in srgb, #6d5bd0 12%, #ffffff) 0%, color-mix(in srgb, #6d5bd0 3%, #ffffff) 100%);
border: 1px solid color-mix(in srgb, #6d5bd0 20%, #ffffff);
color: #6d5bd0;
}
.expense-type-icon--cyan {
background: color-mix(in srgb, #06b6d4 12%, #ffffff);
color: #06b6d4;
background: linear-gradient(135deg, color-mix(in srgb, #0788a2 12%, #ffffff) 0%, color-mix(in srgb, #0788a2 3%, #ffffff) 100%);
border: 1px solid color-mix(in srgb, #0788a2 20%, #ffffff);
color: #0788a2;
}
.expense-type-icon--muted {
background: var(--info-soft, #f1f5f9);
background: linear-gradient(135deg, var(--info-soft, #f1f5f9) 0%, #ffffff 100%);
border: 1px solid var(--workbench-line);
color: var(--workbench-muted, #64748b);
}
@@ -877,3 +988,22 @@
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
color: var(--workbench-primary-active);
}
@keyframes workbenchItemIn {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.assistant-hero,
.capability-card,
.workbench-card {
animation: none !important;
}
}

View File

@@ -1179,6 +1179,136 @@
background: var(--theme-primary-active);
}
@media (min-width: 961px) and (max-width: 1440px),
(min-width: 961px) and (max-height: 820px) {
.topbar {
gap: 16px;
padding: 12px 20px 14px;
}
.topbar.chat-mode {
padding-bottom: 12px;
}
.eyebrow {
margin-bottom: 5px;
padding: 2px 8px;
font-size: 10px;
letter-spacing: 0.8px;
}
.topbar h1 {
font-size: 22px;
line-height: 1.16;
}
.topbar p {
display: -webkit-box;
max-width: 640px;
margin-top: 3px;
overflow: hidden;
color: #64748b;
font-size: 12.5px;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.top-actions {
gap: 10px;
}
.range-shell {
height: 36px;
padding: 2px;
}
.range-meta {
height: 30px;
gap: 6px;
padding: 0 10px;
font-size: 12px;
}
.range-tabs button {
height: 30px;
min-width: 48px;
padding: 0 10px;
font-size: 12px;
}
.custom-range-btn {
height: 36px;
gap: 6px;
padding: 0 11px;
font-size: 12px;
}
.dashboard-switch-wrap {
width: 176px;
flex-basis: 176px;
height: 38px;
}
.dashboard-switch-select :deep(.el-select__wrapper) {
height: 38px;
min-height: 38px;
}
.topbar-toolset {
gap: 12px;
}
.topbar-icon-btn {
width: 30px;
height: 30px;
font-size: 20px;
}
.company-switcher {
height: 34px;
padding: 0 12px;
font-size: 12px;
}
.kpi-chips {
gap: 8px;
}
.kpi-chip {
gap: 1px 8px;
padding: 6px 12px;
}
.chip-value {
font-size: 18px;
}
.chip-value small {
font-size: 11px;
}
.chip-label {
font-size: 11px;
}
.chip-delta {
font-size: 10px;
}
.detail-alert-pill {
min-height: 28px;
padding: 0 10px;
font-size: 11.5px;
}
.create-top-btn {
height: 36px;
padding: 0 14px;
font-size: 13px;
}
}
@media (max-width: 1120px) {
.range-combo {
width: 100%;

View File

@@ -99,6 +99,13 @@
opacity: 0.58;
}
.reimbursement-draft-pending-detail {
display: inline;
margin-left: 8px;
color: #94a3b8;
font-weight: 760;
}
.application-draft-preview .application-draft-head {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;

View File

@@ -60,7 +60,7 @@
max-width: min(100%, 760px);
padding: 12px 14px;
border: 1px solid #d8e4f0;
border-radius: 14px;
border-radius: 4px;
background: #ffffff;
color: #24324a;
font-size: var(--wb-fs-bubble, 13px);
@@ -182,11 +182,54 @@
max-width: min(100%, 1080px);
}
.message-feedback-bubble {
grid-column: 2;
justify-self: start;
max-width: min(100%, 420px);
.message-action-toolbar {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: -2px;
color: #728197;
}
.message-action-btn {
width: 30px;
height: 28px;
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: inherit;
cursor: pointer;
transition:
color 160ms ease,
background 160ms ease,
border-color 160ms ease;
}
.message-action-btn i {
font-size: 18px;
line-height: 1;
}
.message-action-btn:hover,
.message-action-btn:focus-visible {
color: #245f90;
background: #eef6fb;
border-color: #c9ddea;
outline: none;
}
.message-action-btn.active {
color: #1d6f9f;
background: #e8f4fb;
border-color: #bcd8e8;
}
.message-action-btn:disabled {
cursor: not-allowed;
color: #b7c2cf;
background: transparent;
border-color: transparent;
}
.message-bubble-review-risk-low,
@@ -482,7 +525,7 @@
margin: 10px 0 12px;
overflow-x: auto;
border: 1px solid #dbe4ee;
border-radius: 10px;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
}
@@ -566,7 +609,7 @@
align-items: center;
gap: 6px;
padding: 0 12px;
border-radius: 8px;
border-radius: 4px;
font-size: var(--wb-fs-chip, 12px);
font-weight: 750;
}
@@ -606,6 +649,44 @@
gap: 8px;
}
.structured-card-reveal-enter-active {
transition:
opacity 220ms cubic-bezier(0.2, 0, 0, 1),
transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
clip-path 240ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: top left;
will-change: opacity, transform, clip-path;
}
.structured-card-reveal-leave-active {
transition:
opacity 140ms ease,
transform 140ms ease;
transform-origin: top left;
}
.structured-card-reveal-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.985);
clip-path: inset(0 0 14px 0);
}
.structured-card-reveal-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
clip-path: inset(0 0 0 0);
}
.structured-card-reveal-leave-from {
opacity: 1;
transform: translateY(0);
}
.structured-card-reveal-leave-to {
opacity: 0;
transform: translateY(4px);
}
.message-suggested-action-btn {
height: 100%;
min-height: 54px;
@@ -614,16 +695,32 @@
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border-radius: 4px;
text-align: left;
}
.structured-card-reveal-enter-active .message-suggested-action-btn {
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(2) {
animation-delay: 45ms;
}
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(3) {
animation-delay: 90ms;
}
.structured-card-reveal-enter-active .message-suggested-action-btn:nth-child(n + 4) {
animation-delay: 120ms;
}
.message-suggested-action-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 8px;
border-radius: 4px;
background: #eff6ff;
color: var(--theme-primary, #3a7ca5);
}
@@ -651,7 +748,7 @@
margin-top: 12px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: 4px;
background: #f8fbff;
}
@@ -670,12 +767,17 @@
padding: 0;
overflow: hidden;
border: 1px solid #d7e4f2;
border-radius: 8px;
border-radius: 4px;
background: #ffffff;
color: #334155;
font-size: var(--wb-fs-bubble, 13px);
}
.application-preview-shell {
min-width: 0;
display: grid;
}
.application-preview-row {
position: relative;
display: grid;
@@ -684,6 +786,30 @@
border-top: 1px solid #e6edf5;
}
.structured-card-reveal-enter-active .application-preview-row {
animation: structured-card-item-reveal 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(2) {
animation-delay: 35ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(3) {
animation-delay: 70ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(4) {
animation-delay: 105ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(5) {
animation-delay: 140ms;
}
.structured-card-reveal-enter-active .application-preview-row:nth-child(n + 6) {
animation-delay: 165ms;
}
.application-preview-row.editable {
cursor: pointer;
}
@@ -786,7 +912,7 @@
height: 30px;
padding: 0 9px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.48);
border-radius: 6px;
border-radius: 4px;
background: #ffffff;
color: #0f172a;
font: inherit;
@@ -809,7 +935,7 @@
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
border-radius: 4px;
background: var(--theme-primary-soft, #eaf4fa);
color: var(--theme-primary-active, #255b7d);
cursor: pointer;
@@ -903,7 +1029,7 @@
min-height: 22px;
padding: 0 7px;
border: 0;
border-radius: 6px;
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active, #255b7d);
font-weight: 880;
@@ -914,6 +1040,17 @@
font-weight: 820;
}
@keyframes structured-card-item-reveal {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.expense-query-record-list,
.message-citation-list {
display: grid;
@@ -926,7 +1063,7 @@
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 10px;
border-radius: 10px;
border-radius: 4px;
text-align: left;
}
@@ -1054,7 +1191,7 @@
.message-bubble {
max-width: 100%;
border-radius: 12px;
border-radius: 4px;
}
.steward-task-missing-list li {
@@ -1062,3 +1199,15 @@
gap: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
.structured-card-reveal-enter-active,
.structured-card-reveal-leave-active {
transition: none;
}
.structured-card-reveal-enter-active .application-preview-row,
.structured-card-reveal-enter-active .message-suggested-action-btn {
animation: none;
}
}

View File

@@ -208,3 +208,22 @@
) {
border-radius: var(--enterprise-detail-radius);
}
.digital-work-records.digital-work-records .work-record-detail-page :is(
.detail-inline-state,
.detail-loading-state,
.detail-hero,
.enterprise-detail-card,
.json-risk-meta-item,
.json-risk-description-text,
.work-record-error-text,
.work-record-tool-item,
.work-record-inline-empty,
.work-record-code-block,
.edit-badge,
.back-action,
.minor-action,
.major-action
) {
border-radius: var(--enterprise-detail-radius);
}

View File

@@ -32,7 +32,7 @@
--el-text-color-primary: var(--ink);
--el-text-color-regular: var(--text);
--el-text-color-secondary: var(--muted);
--el-font-family: Inter, "SF Pro Text", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
--el-font-family: var(--font-sans);
--el-font-size-base: 14px;
--el-box-shadow-light: 0 8px 22px rgba(15, 23, 42, 0.08);
--el-box-shadow-lighter: 0 4px 14px rgba(15, 23, 42, 0.06);

View File

@@ -72,7 +72,8 @@
--desktop-stage-height: 100dvh;
--desktop-viewport-width: 1440;
--desktop-viewport-height: 900;
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
font-family: var(--font-sans);
}
* { box-sizing: border-box; }

View File

@@ -41,20 +41,6 @@
align-items: flex-start;
}
.budget-select-filter {
display: inline-flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.budget-select-filter .enterprise-select {
min-width: 118px;
}
.budget-primary-btn,
.budget-ghost-btn {
min-height: 38px;
@@ -464,17 +450,11 @@
padding: 12px 12px 0;
}
.budget-select-filter,
.budget-select-filter .enterprise-select,
.budget-primary-btn,
.budget-ghost-btn {
width: 100%;
}
.budget-select-filter {
justify-content: space-between;
}
.budget-scope-tabs {
gap: 18px;
flex-wrap: nowrap;

View File

@@ -15,173 +15,6 @@
overflow: hidden;
}
.document-filter,
.date-range-filter {
position: relative;
}
.document-filter-menu,
.date-range-popover {
position: absolute;
z-index: 40;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow: hidden;
}
.document-filter-menu {
top: calc(100% + 8px);
left: 0;
min-width: 150px;
max-height: 280px;
padding: 6px;
overflow-y: auto;
}
.document-filter-menu button {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
}
.document-filter-menu button:hover,
.document-filter-menu button.active {
background: rgba(58, 124, 165, 0.1);
color: var(--theme-primary-active);
}
.date-range-trigger {
min-width: 150px;
}
.date-range-label {
max-width: 104px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-popover {
top: calc(100% + 8px);
left: 0;
width: 320px;
display: grid;
gap: 14px;
padding: 16px;
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #64748b;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 4px;
color: #0f172a;
font-size: 13px;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 4px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: var(--theme-primary);
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.document-status-filter {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 38px;
}
.status-dropdown-filter {
min-width: 154px;
}
.status-filter-trigger {
min-width: 154px;
}
.status-filter-trigger > .mdi:first-child {
color: var(--theme-primary);
}
.status-filter-menu {
min-width: 154px;
}
.col-id { width: 11%; }
.col-created { width: 10%; }
.col-stay { width: 9%; }

View File

@@ -40,10 +40,6 @@
width: 280px;
}
.system-logs-list .document-filter {
position: relative;
}
.system-logs-list .status-dropdown-filter,
.system-logs-list .status-filter-trigger,
.system-logs-list .status-filter-menu {
@@ -74,42 +70,6 @@
font-size: 18px;
}
.system-logs-list .document-filter-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 40;
min-width: 150px;
max-height: 280px;
padding: 6px;
overflow-y: auto;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
}
.system-logs-list .document-filter-menu button {
width: 100%;
min-height: 36px;
display: block;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
}
.system-logs-list .document-filter-menu button:hover,
.system-logs-list .document-filter-menu button.active {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active);
}
.system-logs-list .system-log-table {
min-width: 1260px;
}

View File

@@ -114,44 +114,46 @@
border-right: 1px solid #edf2f7;
padding-right: 12px;
}
.folder-tree {
min-height: 0;
display: grid;
align-content: start;
gap: 6px;
overflow-y: auto;
}
.folder-tree button {
min-height: 34px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 7px;
background: transparent;
color: #334155;
font-size: 13px;
text-align: left;
}
.folder-tree {
min-height: 0;
display: grid;
align-content: start;
gap: 6px;
overflow-y: auto;
}
.folder-tree button {
min-height: 34px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 7px;
background: transparent;
color: #334155;
font-size: 13px;
text-align: left;
animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: var(--delay, 0ms);
}
.folder-tree button.active {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
font-weight: 850;
}
.folder-tree b {
min-width: 24px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
}
@@ -189,7 +191,7 @@
color: #64748b;
box-shadow: none;
}
.document-area {
min-width: 0;
min-height: 0;
@@ -201,51 +203,51 @@
.document-area.read-only {
grid-template-rows: minmax(0, 1fr) auto;
}
.upload-input {
display: none;
}
.upload-zone {
min-height: 112px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
border: 1px dashed #93c5fd;
border-radius: 10px;
background: #f8fbff;
color: #334155;
text-align: center;
cursor: pointer;
transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease;
}
.upload-zone:hover {
border-color: #60a5fa;
background: #f3f8ff;
}
.upload-zone.disabled {
cursor: default;
border-color: #cbd5e1;
background: #f8fafc;
}
.upload-zone.busy {
opacity: 0.72;
}
.upload-zone i {
color: #2563eb;
font-size: 31px;
}
.upload-zone strong {
font-size: 13px;
font-weight: 850;
}
.upload-input {
display: none;
}
.upload-zone {
min-height: 112px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
border: 1px dashed #93c5fd;
border-radius: 10px;
background: #f8fbff;
color: #334155;
text-align: center;
cursor: pointer;
transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease;
}
.upload-zone:hover {
border-color: #60a5fa;
background: #f3f8ff;
}
.upload-zone.disabled {
cursor: default;
border-color: #cbd5e1;
background: #f8fafc;
}
.upload-zone.busy {
opacity: 0.72;
}
.upload-zone i {
color: #2563eb;
font-size: 31px;
}
.upload-zone strong {
font-size: 13px;
font-weight: 850;
}
.upload-zone span {
color: #64748b;
font-size: 12px;
@@ -255,24 +257,24 @@
min-height: 0;
overflow: auto;
}
table {
width: 100%;
min-width: 780px;
border-collapse: collapse;
}
th,
td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 12px;
line-height: 1.35;
text-align: left;
vertical-align: middle;
}
th,
td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 12px;
line-height: 1.35;
text-align: left;
vertical-align: middle;
}
th {
background: #f7fafc;
color: #64748b;
@@ -289,59 +291,61 @@ th {
.knowledge-document-table td:first-child {
text-align: left;
}
.doc-row {
cursor: pointer;
transition: background 180ms ease, box-shadow 180ms ease;
}
.doc-row:hover {
background: #f8fbff;
}
.doc-row {
cursor: pointer;
animation: listRowIn 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: var(--delay, 0ms);
transition: background 180ms ease, box-shadow 180ms ease;
}
.doc-row:hover {
background: #f8fbff;
}
.doc-row.selected {
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb), 0.08), rgba(59, 130, 246, 0.04));
box-shadow: inset 3px 0 0 var(--theme-primary);
}
.file-name {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 750;
white-space: nowrap;
}
.file-name .pdf,
.viewer-filetype.pdf { color: #ef4444; }
.file-name .word,
.viewer-filetype.word { color: #2563eb; }
.file-name {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 750;
white-space: nowrap;
}
.file-name .pdf,
.viewer-filetype.pdf { color: #ef4444; }
.file-name .word,
.viewer-filetype.word { color: #2563eb; }
.file-name .excel,
.viewer-filetype.excel { color: var(--success); }
.doc-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 7px;
border-radius: 6px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.state-tag {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.doc-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 7px;
border-radius: 6px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.state-tag {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.state-tag.success {
background: var(--success-soft);
color: var(--success-hover);
@@ -351,7 +355,7 @@ th {
background: #e2e8f0;
color: #475569;
}
.state-tag.warning {
background: #ffedd5;
color: #f97316;
@@ -373,14 +377,14 @@ th {
line-height: 1.4;
white-space: nowrap;
}
.more-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: #2563eb;
}
@@ -417,43 +421,43 @@ th {
gap: 4px;
justify-content: center;
}
.empty-row {
color: #64748b;
text-align: center;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 8px;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 8px;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
@@ -465,101 +469,101 @@ th {
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.list-foot .page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.page-nav {
color: #64748b;
}
.list-foot .page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.page-nav {
color: #64748b;
}
.page-size-select {
width: 112px;
justify-self: end;
}
.preview-panel {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 20px 22px;
overflow: hidden;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 16px;
border-bottom: 1px solid #edf2f7;
}
.preview-copy {
min-width: 0;
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mini-action,
.icon-action,
.viewer-toolbar-actions button {
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
}
.mini-action {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
font-size: 12px;
font-weight: 800;
}
.icon-action {
width: 34px;
height: 34px;
display: grid;
place-items: center;
}
.preview-summary-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.preview-secondary-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #1e293b;
color: #e2e8f0;
font-size: 12px;
line-height: 1.5;
}
padding: 20px 22px;
overflow: hidden;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 16px;
border-bottom: 1px solid #edf2f7;
}
.preview-copy {
min-width: 0;
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mini-action,
.icon-action,
.viewer-toolbar-actions button {
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
}
.mini-action {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
font-size: 12px;
font-weight: 800;
}
.icon-action {
width: 34px;
height: 34px;
display: grid;
place-items: center;
}
.preview-summary-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.preview-secondary-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #1e293b;
color: #e2e8f0;
font-size: 12px;
line-height: 1.5;
}
.preview-viewer {
min-height: 0;
margin-top: 18px;
@@ -585,6 +589,7 @@ th {
min-height: 0;
}
.preview-modal-panel {
height: 100%;
border-radius: 24px;
@@ -1401,3 +1406,22 @@ th {
grid-template-columns: 1fr;
}
}
@keyframes listRowIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.folder-tree button,
.doc-row,
.page-sheet {
animation: none !important;
}
}

View File

@@ -365,8 +365,10 @@
}
.dialog-panel {
flex: 1 1 auto;
flex: 1 1 0;
height: auto;
min-height: 0;
max-height: 100%;
}
.insight-panel-shell {

View File

@@ -619,10 +619,13 @@
.assistant-layout {
min-height: 0;
flex: 1;
height: 100%;
max-height: 100%;
display: flex;
padding: clamp(12px, 1.5vw, 16px);
align-items: stretch;
gap: clamp(12px, 1.5vw, 16px);
overflow: hidden;
}
.dialog-panel,
@@ -641,8 +644,11 @@
.dialog-panel {
flex: 1 1 auto;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
min-height: 0;
overflow: hidden;
background: #ffffff;
transition:
@@ -671,6 +677,7 @@
}
.dialog-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
@@ -766,12 +773,15 @@
}
.message-list {
flex: 1 1 0;
min-height: 0;
max-height: 100%;
display: grid;
align-content: start;
gap: 14px;
padding: 18px;
overflow-y: auto;
overscroll-behavior: contain;
}
.message-row.user .message-avatar {
@@ -1918,6 +1928,13 @@
padding: 0 18px 18px;
display: grid;
gap: 12px;
position: sticky;
bottom: 0;
z-index: 20;
flex: 0 0 auto;
flex-shrink: 0;
background: #ffffff;
box-shadow: 0 -10px 22px rgba(248, 250, 252, 0.92);
}
.hidden-file-input {
@@ -1994,3 +2011,37 @@
font-size: 13px;
font-weight: 900;
}
.message-row-reveal-enter-active {
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.message-row-reveal-enter-from {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
.message-row-reveal-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
}
.message-row-reveal-leave-active {
transition: opacity 200ms ease, transform 200ms ease;
position: absolute;
width: 100%;
}
.message-row-reveal-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
.message-row-reveal-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
.message-row-reveal-move {
transition: transform 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

View File

@@ -1,5 +1,7 @@
# Workbench Icons
Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License).
Icons in this folder are SVG assets used by the Personal Workbench todo,
progress, and assistant capability entries.
Used on the Personal Workbench todo and progress lists.
The assistant capability icons are custom line icons designed for the
X-Financial workbench visual system.

View File

@@ -1,6 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8.5 4.5h7A1.5 1.5 0 0 1 17 6v13a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 19V6a1.5 1.5 0 0 1 1.5-1.5h2"/>
<path d="M8.5 4.5A2 2 0 0 1 10.5 3h2A2 2 0 0 1 14.5 4.5v1h-6Z"/>
<path d="M8.5 12.8 11 15.2l5-5.2"/>
<path d="M8 18h7"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path class="icon-fill" d="M7.1 4.7h8.4c.9 0 1.6.7 1.6 1.6v12.3c0 .9-.7 1.6-1.6 1.6H7.1c-.9 0-1.6-.7-1.6-1.6V6.3c0-.9.7-1.6 1.6-1.6Z"/>
<path d="M8.5 4.7H7.1c-.9 0-1.6.7-1.6 1.6v12.3c0 .9.7 1.6 1.6 1.6h8.4c.9 0 1.6-.7 1.6-1.6V6.3c0-.9-.7-1.6-1.6-1.6h-1.3"/>
<path class="icon-accent" d="M8.5 4.7c.2-.9 1-1.6 2-1.6h1.8c1 0 1.8.7 2 1.6v1.2H8.5Z"/>
<path d="m8.7 12.6 2.3 2.2 4.9-5.1"/>
<path class="icon-muted" d="M8.2 18h6.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -1,5 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4.5v7h7A7 7 0 1 1 12 4.5Z"/>
<path d="M14.5 3.8A7.2 7.2 0 0 1 20.2 9h-5.7Z"/>
<path d="M7.5 16.5h4M7.5 13.5H10"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path class="icon-fill" d="M11.6 4.2a7.5 7.5 0 1 0 7.5 7.5h-7.5Z"/>
<path d="M11.6 4.2a7.5 7.5 0 1 0 7.5 7.5h-7.5Z"/>
<path class="icon-accent" d="M14.3 3.9a7.3 7.3 0 0 1 5.8 5.7h-5.8Z"/>
<path d="M7.5 15.7h4.5"/>
<path class="icon-muted" d="M7.5 18h3.1"/>
<path d="M16.8 14.6v2.9M19.2 13.2v4.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 320 B

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -1,6 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 3.5h7l3 3v13a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5v-15A1.5 1.5 0 0 1 7.5 3.5Z"/>
<path d="M14 3.5V7h3.5"/>
<path d="M9 11h6M9 14h3"/>
<path d="M15.5 13.5v4M13.5 15.5h4"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path class="icon-fill" d="M7.5 3.7h7.1l3.2 3.2v11.7c0 .9-.7 1.6-1.6 1.6H7.5c-.9 0-1.6-.7-1.6-1.6V5.3c0-.9.7-1.6 1.6-1.6Z"/>
<path d="M7.5 3.7h7.1l3.2 3.2v11.7c0 .9-.7 1.6-1.6 1.6H7.5c-.9 0-1.6-.7-1.6-1.6V5.3c0-.9.7-1.6 1.6-1.6Z"/>
<path class="icon-accent" d="M14.5 3.8v3.3h3.3"/>
<path d="M8.8 10.4h6.4M8.8 13h4.2"/>
<path class="icon-muted" d="M8.8 15.7h2.6"/>
<path d="M16.4 13.9v4.1M14.4 16h4.1"/>
</svg>

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 603 B

View File

@@ -1,6 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4.5 19.5h15"/>
<path d="M6 16.5V8.8M10 16.5V5.5M14 16.5v-6M18 16.5V7"/>
<path d="M6 12.5c2.2-3.2 4-1.2 6.2-4.1 1.5-2 3-1.3 5.3-3.4"/>
<path d="m17.6 5 .4 3.2-3.1-.5"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path class="icon-fill" d="M5.2 5.5h14v13.2h-14Z"/>
<path d="M4.8 19h14.8"/>
<path class="icon-muted" d="M6.5 16.2V9.8M10.3 16.2V7.2M14.1 16.2v-5.7M17.9 16.2V8.6"/>
<path d="M6.4 13c2.2-3.1 4.1-1.4 6-4 1.5-2 3.2-1.1 5.4-3.2"/>
<path class="icon-accent" d="m17.9 5.8.2 3-2.8-.5"/>
<circle cx="12.4" cy="9" r="1" class="icon-fill"/>
</svg>

Before

Width:  |  Height:  |  Size: 373 B

After

Width:  |  Height:  |  Size: 531 B

View File

@@ -1,6 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5.5 5.5A2.5 2.5 0 0 1 8 3h12v16.5H8A2.5 2.5 0 0 0 5.5 22Z"/>
<path d="M5.5 5.5v16"/>
<path d="M9 7.5h7M9 10.5h6M9 14.5h4"/>
<path d="M17 3v16.5"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path class="icon-fill" d="M5.5 5.7A2.4 2.4 0 0 1 7.9 3.3h11.2v16.2H7.9a2.4 2.4 0 0 0-2.4 2.2Z"/>
<path d="M5.5 5.7A2.4 2.4 0 0 1 7.9 3.3h11.2v16.2H7.9a2.4 2.4 0 0 0-2.4 2.2Z"/>
<path class="icon-muted" d="M5.5 5.7v16"/>
<path d="M9 7.4h5.6M9 10.2h4.8M9 13h3.7"/>
<path class="icon-accent" d="M16 3.3v5.2l1.4-1 1.4 1V3.3"/>
<path d="M14.8 15.2h2.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 352 B

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -1,6 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6.5 4.5h9.8A1.7 1.7 0 0 1 18 6.2v13.3l-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2V6.2a1.7 1.7 0 0 1 1.7-1.7Z"/>
<path d="M8 9h8M8 12h6"/>
<path d="M9 15.5h2.7c1.8 0 3.3-1.4 3.3-3.2v-.8"/>
<path d="m13.3 10 1.7 1.7 1.7-1.7"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path class="icon-fill" d="M5.2 6.3h11.5c1 0 1.8.8 1.8 1.8v8.1c0 1-.8 1.8-1.8 1.8H5.2c-1 0-1.8-.8-1.8-1.8V8.1c0-1 .8-1.8 1.8-1.8Z"/>
<path d="M5.2 6.3h11.5c1 0 1.8.8 1.8 1.8v8.1c0 1-.8 1.8-1.8 1.8H5.2c-1 0-1.8-.8-1.8-1.8V8.1c0-1 .8-1.8 1.8-1.8Z"/>
<path class="icon-accent" d="M6.8 6.3V4.9c0-.7.5-1.2 1.2-1.2h8.9c.7 0 1.2.5 1.2 1.2v2.2"/>
<path d="M7 10.2h5.8M7 13.3h4.1"/>
<path d="m13.2 14.1 1.7 1.6 3.1-3.6"/>
<path class="icon-muted" d="M18.5 9.7h1.7v4.6h-1.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 435 B

After

Width:  |  Height:  |  Size: 667 B

View File

@@ -1,10 +1,10 @@
<template>
<div class="picker-filter" :class="{ open: activeFilterPopover === id }">
<div class="picker-filter document-filter" :class="{ open: activeFilterPopover === id }">
<button
class="picker-trigger"
class="picker-trigger filter-btn"
type="button"
:aria-expanded="activeFilterPopover === id"
aria-haspopup="dialog"
aria-haspopup="listbox"
@click="emit('toggle', id)"
>
<span class="picker-label">{{ label }}</span>
@@ -12,28 +12,21 @@
</button>
<div
v-if="activeFilterPopover === id"
class="picker-popover"
role="dialog"
class="picker-popover document-filter-menu"
role="listbox"
:aria-label="title"
>
<header>
<strong>{{ title }}</strong>
<button type="button" :aria-label="closeLabel" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in options"
:key="option.value || `all-${id}`"
type="button"
class="picker-option"
:class="{ active: selectedValue === option.value }"
@click="emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
<button
v-for="option in options"
:key="option.value || `all-${id}`"
type="button"
role="option"
:aria-selected="selectedValue === option.value"
:class="{ active: selectedValue === option.value }"
@click="emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
@@ -56,5 +49,4 @@ defineProps({
const emit = defineEmits(['toggle', 'close', 'select'])
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
<style scoped src="../../assets/styles/components/document-list-shared.css"></style>

View File

@@ -1,12 +1,13 @@
<template>
<article v-if="visible" class="detail-card panel run-products-card">
<div class="card-head">
<div>
<h3>本次任务产物</h3>
<p>{{ productSubtitle }}</p>
</div>
<EnterpriseDetailCard
v-if="visible"
class="run-products-card"
title="本次任务产物"
:description="productSubtitle"
>
<template #actions>
<span class="edit-badge">{{ productBadge }}</span>
</div>
</template>
<div v-if="loading" class="run-product-state">
<i class="mdi mdi-loading mdi-spin"></i>
@@ -211,12 +212,13 @@
</div>
</section>
</template>
</article>
</EnterpriseDetailCard>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import EnterpriseDetailCard from '../shared/EnterpriseDetailCard.vue'
import { fetchRunRiskObservations } from '../../services/riskObservations.js'
import {
extractWorkRecordToolSummary,

View File

@@ -157,34 +157,26 @@
</template>
</EnterpriseListPage>
<!-- 详情视图 (全屏样式,参考 AuditJsonRiskRuleDetail) -->
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage">
<div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;">
<TableLoadingState
variant="panel"
title="详情加载中"
message="正在读取该次工作记录的完整执行信息"
icon="mdi mdi-clipboard-text-search-outline"
/>
</div>
<div v-else-if="detailError" class="work-record-detail-state error panel" style="min-height: 200px; display: grid; place-items: center; text-align: center; border: 0; color: #dc2626;">
<i class="mdi mdi-alert-circle-outline" style="font-size: 32px; margin-bottom: 8px;"></i>
<strong>工作记录详情加载失败</strong>
<p>{{ detailError }}</p>
<button class="minor-action" type="button" @click="reloadSelectedDetail" style="margin-top: 12px;">重新加载</button>
</div>
<div v-else class="json-risk-editor-body work-record-detail-shell">
<section class="json-risk-main-stage work-record-detail-body inline-detail">
<!-- 卡片1基本信息 -->
<article class="detail-card panel json-risk-summary-card">
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>此次运行的执行周期、触发来源、标识信息与最终状态。</p>
</div>
</div>
<EnterpriseDetailPage
v-else
key="detail"
variant="work-record-detail-page"
actions-class="work-record-detail-actions"
:loading="detailLoading"
:error="detailError"
error-title="工作记录详情加载失败"
loading-title="详情加载中"
loading-message="正在读取该次工作记录的完整执行信息"
loading-icon="mdi mdi-clipboard-text-search-outline"
back-label="返回工作记录列表"
@back="closeWorkRecordDetail"
>
<template #main>
<EnterpriseDetailCard
class="work-record-detail-card work-record-summary-card"
title="基本信息"
description="此次运行的执行周期触发来源标识信息与最终状态"
>
<div class="json-risk-meta-grid">
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">Run ID</span>
@@ -211,75 +203,63 @@
<span class="json-risk-meta-value">{{ resolveWorkRecordStatusNote(selectedRunDetail) || '-' }}</span>
</div>
</div>
</article>
</EnterpriseDetailCard>
<!-- 卡片2执行摘要 -->
<article class="detail-card panel json-risk-description-card">
<div class="card-head">
<div>
<h3>执行摘要</h3>
<p>本次数字员工工作流的执行内容与结果摘要。</p>
</div>
<EnterpriseDetailCard
class="work-record-detail-card work-record-summary-copy-card"
title="执行摘要"
description="本次数字员工工作流的执行内容与结果摘要"
>
<template #actions>
<span class="edit-badge">{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
</div>
<p class="json-risk-description-text" style="padding: 0 12px 12px; margin: 0;">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text" style="margin: 0 12px 12px; padding: 10px 12px; border: 1px solid #fecaca; border-radius: 4px; background: #fef2f2; color: #b91c1c;">
</template>
<p class="json-risk-description-text work-record-summary-text">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text">
{{ selectedRunDetail.error_message }}
</p>
</article>
</p>
</EnterpriseDetailCard>
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
</template>
<!-- 卡片3工具调用 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>工具调用</h3>
<p>此任务在执行期间调用的外部系统/工具细节与执行状态。</p>
</div>
<template #side>
<EnterpriseDetailCard
class="work-record-detail-card"
title="工具调用"
description="此任务在执行期间调用的外部系统/工具细节与执行状态"
>
<template #actions>
<span class="edit-badge">{{ (selectedRunDetail.tool_calls || []).length }} 次调用</span>
</div>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list" style="padding: 0 12px 12px; display: grid; gap: 8px;">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #edf2f7; border-radius: 4px; background: #f8fafc;">
<strong style="color: #0f172a; font-size: 13px;">{{ toolCall.tool_name }}</strong>
<span style="color: #64748b; font-size: 12px;">{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</template>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" class="work-record-tool-item">
<strong>{{ toolCall.tool_name }}</strong>
<span>{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</article>
</div>
<div v-else class="work-record-inline-empty" style="padding: 0 12px 12px; color: #94a3b8; font-size: 13px;">当前暂无工具调用明细。</div>
</article>
</div>
<div v-else class="work-record-inline-empty">当前暂无工具调用明细。</div>
</EnterpriseDetailCard>
<!-- 卡片4执行上下文 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>执行上下文</h3>
<p>后台调度的运行时配置与状态信息JSON 格式)。</p>
</div>
</div>
<div style="padding: 0 12px 12px;">
<pre class="work-record-code-block" style="max-height: 320px; margin: 0; padding: 12px; overflow: auto; border: 1px solid #e2e8f0; border-radius: 4px; background: #0f172a; color: #e2e8f0; font-size: 12px; line-height: 1.55;">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</div>
</article>
</section>
</div>
<EnterpriseDetailCard
class="work-record-detail-card"
title="执行上下文"
description="后台调度的运行时配置与状态信息JSON 格式"
>
<pre class="work-record-code-block">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</EnterpriseDetailCard>
</template>
<footer class="detail-actions">
<button class="back-action" type="button" @click="closeWorkRecordDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回工作记录列表</span>
<template #actions>
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
</button>
</div>
</footer>
</div>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
</button>
</template>
</EnterpriseDetailPage>
</Transition>
</section>
</template>
@@ -290,8 +270,9 @@ import { useRouter } from 'vue-router'
import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
import EnterpriseDetailCard from '../shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../shared/EnterpriseDetailPage.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
import { useToast } from '../../composables/useToast.js'
import {
@@ -650,103 +631,5 @@ onBeforeUnmount(() => {
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
<style scoped>
.digital-work-records {
height: 100%;
}
.digital-employee-list-panel,
.digital-work-records-list-stage {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.digital-work-records-table {
min-width: 1180px;
table-layout: fixed;
}
.digital-work-records-table .col-time { width: 14%; }
.digital-work-records-table .col-module { width: 13%; }
.digital-work-records-table .col-source { width: 10%; }
.digital-work-records-table .col-status { width: 16%; }
.digital-work-records-table .col-summary { width: 31%; }
.digital-work-records-table .col-trace { width: 16%; }
.work-record-row {
outline: none;
}
.work-record-row:focus-visible {
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
}
.work-record-summary-cell {
text-align: left !important;
}
.work-record-summary-cell strong,
.work-record-summary-cell span,
.work-record-summary-cell em {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-record-summary-cell strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.work-record-summary-cell span {
margin-top: 4px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.work-record-summary-cell em {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
font-style: normal;
}
.work-record-trace-cell {
color: #2563eb !important;
}
.work-records-detail-stage,
.work-record-detail-shell {
flex: 1 1 0;
min-height: 0;
}
.digital-work-records :deep(.toolbar-actions .picker-filter),
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
min-width: 148px;
}
.digital-refresh-now {
width: 40px;
min-width: 40px;
padding: 0;
}
.digital-refresh-now .mdi {
font-size: 18px;
}
.work-record-detail-body.inline-detail {
padding: 0;
overflow: visible;
background: transparent;
}
</style>
<style scoped src="../../assets/styles/components/digital-employee-work-records.css"></style>
<style scoped src="../../assets/styles/components/digital-employee-work-records-overrides.css"></style>

View File

@@ -9,7 +9,9 @@
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy">
<h1>{{ displayUserName }}我是您的 <span>小财管家</span></h1>
<h1 class="assistant-hero-title">
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span>
</h1>
<input
ref="fileInputRef"
@@ -173,11 +175,12 @@
<div :class="['capability-grid', capabilityGridClass]" aria-label="AI 财务助手能力">
<button
v-for="item in visibleAssistantCapabilities"
v-for="(item, index) in visibleAssistantCapabilities"
:key="item.key"
type="button"
class="capability-card panel"
:class="`capability-card--${item.tone}`"
:style="{ '--delay': `${index * 80 + 100}ms` }"
@click="openCapabilityAssistant(item)"
>
<WorkbenchListIcon
@@ -196,18 +199,19 @@
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card progress-panel">
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
<div class="section-head">
<h2>费用进度</h2>
</div>
<div class="progress-list">
<button
v-for="item in visibleProgressItems"
v-for="(item, index) in visibleProgressItems"
:key="item.id"
type="button"
class="progress-row"
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
:style="{ '--item-index': index }"
@click="openWorkbenchTarget(item)"
>
<span class="progress-time-wrapper">
@@ -256,7 +260,7 @@
</article>
<aside class="side-column">
<article class="panel workbench-card side-panel expense-stats-panel">
<article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;">
<div class="section-head side-card-head">
<h2>费用统计</h2>
<button
@@ -273,10 +277,11 @@
<div class="insight-metric-list" aria-label="费用统计">
<div
v-for="item in visibleExpenseStatItems"
v-for="(item, index) in visibleExpenseStatItems"
:key="item.key"
class="insight-metric-row"
:class="`insight-metric-row--${item.tone}`"
:style="{ '--item-index': index }"
>
<span class="insight-metric-icon" aria-hidden="true">
<i :class="item.icon"></i>
@@ -289,7 +294,7 @@
</div>
</article>
<article class="panel workbench-card side-panel usage-profile-panel">
<article class="panel workbench-card side-panel usage-profile-panel" style="--delay: 400ms;">
<div class="section-head side-card-head">
<h2>用户画像</h2>
<button
@@ -306,10 +311,11 @@
<div class="insight-profile-list" aria-label="用户画像">
<div
v-for="metric in visibleUsageProfileMetrics"
v-for="(metric, index) in visibleUsageProfileMetrics"
:key="metric.key"
class="insight-profile-card"
:class="`insight-profile-card--${metric.tone}`"
:style="{ '--item-index': index }"
>
<span class="insight-profile-icon" aria-hidden="true">
<i :class="metric.icon"></i>
@@ -441,6 +447,35 @@ const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
const heroTitleText = computed(() => `嗨,${displayUserName.value},我是您的 `)
const typedTitlePrefix = ref('')
const titleTypingDone = ref(false)
let typingInterval = null
const startTypewriter = () => {
typedTitlePrefix.value = ''
titleTypingDone.value = false
clearInterval(typingInterval)
let i = 0
const text = heroTitleText.value
typingInterval = setInterval(() => {
if (i < text.length) {
typedTitlePrefix.value += text.charAt(i)
i++
} else {
clearInterval(typingInterval)
titleTypingDone.value = true
}
}, 60)
}
watch(displayUserName, (newVal, oldVal) => {
if (oldVal !== newVal && titleTypingDone.value) {
typedTitlePrefix.value = `嗨,${newVal},我是您的 `
}
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const isComposerPending = computed(() => Boolean(pendingAction.value))
const composerPendingLabel = computed(() => {
@@ -861,6 +896,7 @@ async function handleExpenseConversationAction() {
}
onMounted(() => {
startTypewriter()
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile()
@@ -869,6 +905,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
clearInterval(typingInterval)
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)

View File

@@ -1,20 +1,25 @@
<template>
<div class="system-agent-ratio-bar">
<div class="agent-ratio-legend" aria-hidden="true">
<span v-for="agent in resolvedAgents" :key="agent.key">
<i :style="{ background: agent.resolvedColor }"></i>{{ agent.name }}
</span>
</div>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { CustomChart as EChartsCustomChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
use([GridComponent, TooltipComponent, EChartsCustomChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
@@ -36,36 +41,31 @@ const ariaLabel = computed(() =>
const parts = resolvedAgents.value.map((agent) => (
`${agent.name}${props.series[agent.key]?.[dayIndex] || 0}%`
))
return `${label}${parts.join('')}`
return `${label}${parts.join('')}`
}).join('')
)
const stackedAgentRatioData = computed(() => props.labels.map((_, index) => [
index,
...resolvedAgents.value.map((agent) => Number(props.series[agent.key]?.[index] || 0))
]))
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 8,
itemHeight: 8,
itemGap: 14,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 38,
top: 8,
right: 16,
bottom: 24,
left: 34,
containLabel: true
},
tooltip: {
trigger: 'axis',
trigger: 'item',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
@@ -78,7 +78,7 @@ const chartOptions = computed(() => ({
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${value}%`
formatter: (params) => formatStackedTooltip(params)
},
xAxis: {
type: 'category',
@@ -104,32 +104,161 @@ const chartOptions = computed(() => ({
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
series: resolvedAgents.value.map((agent, index) => ({
name: agent.name,
type: 'bar',
stack: 'agentRatio',
data: props.series[agent.key] || [],
barWidth: 34,
emphasis: { focus: 'series' },
itemStyle: {
color: agent.resolvedColor,
borderColor: '#ffffff',
borderWidth: index === resolvedAgents.value.length - 1 ? 0 : 1,
borderRadius: index === resolvedAgents.value.length - 1 ? [4, 4, 0, 0] : 0
series: [{
name: '智能体调用占比',
type: 'custom',
data: stackedAgentRatioData.value,
renderItem: renderStackedAgentRatioBar,
animationDelay: (index) => index * 18,
tooltip: {
formatter: (params) => formatStackedTooltip(params)
}
}))
}]
}))
useEcharts(chartElement, chartOptions)
function renderStackedAgentRatioBar(params, api) {
const categoryIndex = Number(api.value(0))
const zeroPoint = api.coord([categoryIndex, 0])
const xCenter = zeroPoint[0]
const zeroY = zeroPoint[1]
const categoryWidth = api.size([1, 0])?.[0] || 48
const barWidth = Math.max(24, Math.min(34, categoryWidth * 0.48))
const barX = xCenter - barWidth / 2
let accumulated = 0
const values = resolvedAgents.value.map((_, index) => Number(api.value(index + 1) || 0))
const lastVisibleIndex = values.reduce((last, value, index) => (value > 0 ? index : last), -1)
const children = []
let topY = zeroY
values.forEach((value, index) => {
if (value <= 0) {
return
}
const lower = accumulated
const upper = accumulated + value
const lowerY = api.coord([categoryIndex, lower])[1]
const upperY = api.coord([categoryIndex, upper])[1]
const height = Math.max(1, lowerY - upperY)
topY = Math.min(topY, upperY)
accumulated = upper
children.push({
type: 'rect',
shape: {
x: barX,
y: upperY,
width: barWidth,
height,
r: index === lastVisibleIndex ? [4, 4, 0, 0] : 0
},
style: {
fill: resolvedAgents.value[index]?.resolvedColor || themeColors.value.chartPrimary,
stroke: index === lastVisibleIndex ? 'transparent' : '#ffffff',
lineWidth: index === lastVisibleIndex ? 0 : 1
}
})
})
if (!children.length) {
return {
type: 'group',
children: []
}
}
const totalHeight = Math.max(1, zeroY - topY)
return {
type: 'group',
originX: xCenter,
originY: zeroY,
scaleY: 1,
enterFrom: {
scaleY: 0
},
transition: ['scaleY'],
clipPath: {
type: 'rect',
shape: {
x: barX,
y: topY,
width: barWidth,
height: totalHeight
},
enterFrom: {
shape: {
x: barX,
y: zeroY,
width: barWidth,
height: 0
}
},
transition: ['shape']
},
children
}
}
function formatStackedTooltip(params) {
const index = Number(params?.data?.[0] ?? params?.dataIndex ?? 0)
const label = props.labels[index] || params?.axisValueLabel || ''
const rows = resolvedAgents.value
.map((agent) => ({
name: agent.name,
color: agent.resolvedColor,
value: Number(props.series[agent.key]?.[index] || 0)
}))
.filter((item) => item.value > 0)
const details = rows.map((item) => (
`<span style="display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;background:${item.color};"></span>${item.name}${item.value}%`
))
return [label, ...details].join('<br/>')
}
</script>
<style scoped>
.system-agent-ratio-bar {
height: 292px;
display: flex;
flex-direction: column;
}
.agent-ratio-legend {
min-height: 30px;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 6px 14px;
color: #475569;
font-size: 12px;
line-height: 1.4;
margin-bottom: 8px;
}
.agent-ratio-legend span {
max-width: 132px;
display: inline-flex;
align-items: center;
min-width: 0;
color: #475569;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-ratio-legend i {
flex: 0 0 auto;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 5px;
}
.chart-body {
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
}
</style>

View File

@@ -364,8 +364,9 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
import { useTopBarWorkbenchPopovers } from '../../composables/useTopBarWorkbenchPopovers.js'
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
currentView: { type: Object, required: true },
@@ -520,7 +521,7 @@ function resolveWorkbenchNotificationId(item, index) {
const documentNotificationItems = computed(() =>
documentInboxNotificationRows.value
.map((row) => {
const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
const id = normalizeNotificationId(resolveDocumentNotificationId(row))
if (!id || isNotificationHidden(id)) {
return null
}

View File

@@ -0,0 +1,57 @@
<template>
<div class="picker-filter document-filter" :class="[rootClass, { open: isOpen }]">
<button
class="picker-trigger filter-btn"
:class="triggerClass"
type="button"
:aria-expanded="isOpen"
aria-haspopup="listbox"
@click="emit('toggle', id)"
>
<i v-if="icon" :class="icon"></i>
<span class="picker-label">{{ label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="isOpen"
class="picker-popover document-filter-menu"
:class="menuClass"
role="listbox"
:aria-label="title || label"
>
<button
v-for="option in options"
:key="option.value || `all-${id}`"
type="button"
role="option"
:aria-selected="selectedValue === option.value"
:class="{ active: selectedValue === option.value }"
@click="emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
id: { type: String, required: true },
title: { type: String, default: '' },
activeFilterKey: { type: String, default: '' },
label: { type: String, default: '' },
options: { type: Array, default: () => [] },
selectedValue: { type: [String, Number, Boolean], default: '' },
rootClass: { type: [String, Array, Object], default: '' },
triggerClass: { type: [String, Array, Object], default: '' },
menuClass: { type: [String, Array, Object], default: '' },
icon: { type: String, default: '' }
})
const emit = defineEmits(['toggle', 'select'])
const isOpen = computed(() => props.activeFilterKey === props.id)
</script>
<style scoped src="../../assets/styles/components/document-list-shared.css"></style>

View File

@@ -70,7 +70,7 @@ const props = defineProps({
severityLabel: { type: String, default: '中风险' }
})
const FONT = "Helvetica, Arial, sans-serif"
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'Segoe UI', Arial, sans-serif"
const TEXT = '#0d0d0d'
const MUTED = '#6e6e80'
const NEUTRAL_LINE = '#cbd5e1'

View File

@@ -39,13 +39,15 @@ const iconStyle = computed(() => iconMeta.value.style)
.workbench-list-icon__halo {
position: absolute;
top: 8px;
bottom: 8px;
left: 0;
width: 3px;
inset: 6px auto 6px -2px;
width: 4px;
border-radius: 2px;
background: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff);
opacity: 0.72;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff),
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 28%, #ffffff)
);
opacity: 0.88;
}
.workbench-list-icon__panel {
@@ -57,18 +59,20 @@ const iconStyle = computed(() => iconMeta.value.style)
place-items: center;
overflow: hidden;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 22%, var(--line, #e2e8f0));
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 24%, var(--line, #e2e8f0));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.44)),
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0) 48%),
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.52)),
linear-gradient(
135deg,
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 64%, #fff) 0%,
#fff 52%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, var(--surface-soft, #f8fafc)) 100%
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 72%, #fff) 0%,
#fff 46%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 12%, var(--surface-soft, #f8fafc)) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 1px 2px rgba(15, 23, 42, 0.045);
inset 0 -1px 0 color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, rgba(255, 255, 255, 0.9)),
0 8px 18px rgba(15, 23, 42, 0.055);
}
.workbench-list-icon__shine {
@@ -95,10 +99,37 @@ const iconStyle = computed(() => iconMeta.value.style)
}
.workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) {
stroke-width: 1.65;
stroke-width: 1.55;
}
.workbench-list-icon__art :deep(.icon-fill) {
fill: currentColor;
stroke: none;
opacity: 0.09;
}
.workbench-list-icon__art :deep(.icon-accent) {
opacity: 0.36;
}
.workbench-list-icon__art :deep(.icon-muted) {
opacity: 0.62;
}
.workbench-list-icon--solid .workbench-list-icon__art :deep(.workbench-heroicon path) {
opacity: 0.96;
}
.workbench-list-icon__art :deep(.workbench-image-icon) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
max-width: none;
object-fit: contain;
display: block;
filter: drop-shadow(0 4px 8px rgba(15, 23, 42, 0.15));
}
</style>

View File

@@ -148,104 +148,111 @@
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<Transition name="structured-card-reveal" appear>
<div
v-for="row in ui.resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click.stop="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-shell"
aria-label="申请信息核对结果"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'text'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<EnterpriseSelect
v-else-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'select'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-select"
:options="ui.resolveApplicationPreviewEditorOptions(row.key)"
clearable
:teleported="false"
autofocus
@click.stop
@change="ui.commitApplicationPreviewEditor(message)"
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<template v-else>
<span
class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
>{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click.stop="ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview && ui.resolveApplicationPreviewMissingFields(message)?.length"
class="application-preview-footer application-preview-footer-missing"
aria-live="polite"
>
<span class="application-preview-missing-prefix">当前还需要补充</span>
<span class="application-preview-missing-list">
<template
v-for="(field, index) in ui.resolveApplicationPreviewMissingFields(message)"
:key="`${message.id}-missing-${field}`"
<div
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<span class="application-preview-missing-chip">{{ field }}</span>
<span
v-if="index < ui.resolveApplicationPreviewMissingFields(message).length - 1"
class="application-preview-missing-separator"
></span>
</template>
</span>
<span class="application-preview-missing-suffix">补齐后我再帮您提交申请</span>
</div>
<div
v-else-if="message.role === 'assistant' && message.applicationPreview && ui.buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in ui.resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click.stop="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'text'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<EnterpriseSelect
v-else-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'select'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-select"
:options="ui.resolveApplicationPreviewEditorOptions(row.key)"
clearable
:teleported="false"
autofocus
@click.stop
@change="ui.commitApplicationPreviewEditor(message)"
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<template v-else>
<span
class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
>{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click.stop="ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="ui.resolveApplicationPreviewMissingFields(message)?.length"
class="application-preview-footer application-preview-footer-missing"
aria-live="polite"
>
<span class="application-preview-missing-prefix">当前还需要补充</span>
<span class="application-preview-missing-list">
<template
v-for="(field, index) in ui.resolveApplicationPreviewMissingFields(message)"
:key="`${message.id}-missing-${field}`"
>
<span class="application-preview-missing-chip">{{ field }}</span>
<span
v-if="index < ui.resolveApplicationPreviewMissingFields(message).length - 1"
class="application-preview-missing-separator"
></span>
</template>
</span>
<span class="application-preview-missing-suffix">补齐后我再帮您提交申请</span>
</div>
<div
v-else-if="ui.buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
</div>
</Transition>
<div
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
@@ -267,32 +274,34 @@
</div>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: ui.isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.handleSuggestedAction(message, action)"
<Transition name="structured-card-reveal" appear>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: ui.isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.handleSuggestedAction(message, action)"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
</Transition>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong>
@@ -481,20 +490,26 @@
</footer>
</template>
<template v-else>
<div class="reimbursement-draft-card" role="group" aria-label="报销草稿已生成">
<div
class="reimbursement-draft-card"
role="group"
:aria-label="ui.canOpenDraftDetail(message) ? '报销草稿已生成' : '报销草稿待保存'"
>
<span class="reimbursement-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-edit-outline"></i>
</span>
<div class="reimbursement-draft-main">
<strong>报销草稿已生成</strong>
<strong>{{ ui.canOpenDraftDetail(message) ? '报销草稿已生成' : '报销草稿待保存' }}</strong>
<p>
单号<span>{{ ui.resolveReimbursementDraftClaimNo(message.draftPayload) }}</span>
<button
v-if="ui.canOpenDraftDetail(message)"
type="button"
class="reimbursement-draft-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看详情</button>
<span v-else class="reimbursement-draft-pending-detail">保存后可查看详情</span>
</p>
</div>
</div>
@@ -580,21 +595,53 @@
</span>
</div>
</div>
</div>
<div
v-if="ui.isOperationFeedbackVisible(message)"
class="message-feedback-bubble"
v-if="ui.shouldShowAssistantMessageActions(message)"
class="message-action-toolbar"
role="toolbar"
aria-label="系统消息操作"
>
<OperationFeedbackInlineCard
:busy="Boolean(message.operationFeedback?.submitting)"
:error-message="message.operationFeedback?.error || ''"
:submitted="Boolean(message.operationFeedback?.submitted)"
:submitted-rating="Number(message.operationFeedback?.rating || 0)"
:reset-key="`${message.id}-${message.operationFeedback?.context?.runId || message.operationFeedback?.context?.run_id || ''}`"
@dismiss="ui.dismissOperationFeedbackForMessage(message)"
@submit="ui.submitOperationFeedbackForMessage(message, $event)"
/>
<button
type="button"
class="message-action-btn"
title="复制"
aria-label="复制"
@click="ui.copyAssistantMessage(message)"
>
<i class="mdi mdi-content-copy"></i>
</button>
<button
type="button"
class="message-action-btn"
title="语音播报"
aria-label="语音播报"
@click="ui.speakAssistantMessage(message)"
>
<i class="mdi mdi-volume-high"></i>
</button>
<button
type="button"
class="message-action-btn"
:class="{ active: ui.isMessageFeedbackSelected(message, 5) }"
:disabled="Boolean(message.operationFeedback?.submitting)"
title="点赞"
aria-label="点赞"
@click="ui.submitOperationFeedbackForMessage(message, { rating: 5, reason: 'thumbs_up' })"
>
<i :class="ui.isMessageFeedbackSelected(message, 5) ? 'mdi mdi-thumb-up' : 'mdi mdi-thumb-up-outline'"></i>
</button>
<button
type="button"
class="message-action-btn"
:class="{ active: ui.isMessageFeedbackSelected(message, 1) }"
:disabled="Boolean(message.operationFeedback?.submitting)"
title="点踩"
aria-label="点踩"
@click="ui.submitOperationFeedbackForMessage(message, { rating: 1, reason: 'thumbs_down' })"
>
<i :class="ui.isMessageFeedbackSelected(message, 1) ? 'mdi mdi-thumb-down' : 'mdi mdi-thumb-down-outline'"></i>
</button>
</div>
</div>
</article>
</template>
@@ -602,14 +649,12 @@
<script>
import BudgetAssistantReport from './BudgetAssistantReport.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import OperationFeedbackInlineCard from '../shared/OperationFeedbackInlineCard.vue'
export default {
name: 'TravelReimbursementMessageItem',
components: {
BudgetAssistantReport,
EnterpriseSelect,
OperationFeedbackInlineCard
EnterpriseSelect
},
props: {
message: {

View File

@@ -1,6 +1,10 @@
import { computed, ref, watch } from 'vue'
import { fetchApprovalExpenseClaims } from '../services/reimbursements.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims
} from '../services/reimbursements.js'
import { canAccessAppView } from '../utils/accessControl.js'
import { resolvePendingClaimIds } from '../utils/approvalInbox.js'
import { useSystemState } from './useSystemState.js'
@@ -109,8 +113,8 @@ export function useApprovalInbox() {
}
try {
const payload = await fetchApprovalExpenseClaims()
syncPendingClaimIds(resolvePendingClaimIds(payload, user))
const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
syncPendingClaimIds(resolvePendingClaimIds(extractExpenseClaimItems(payload), user))
} catch {
pendingClaimIds.value = []
}

View File

@@ -1,12 +1,20 @@
import { computed, ref } from 'vue'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseClaims } from '../services/reimbursements.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims,
fetchArchivedExpenseClaims,
fetchExpenseClaims
} from '../services/reimbursements.js'
import { fetchNotificationStates } from '../services/notificationStates.js'
import {
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
countNewDocuments,
isNewDocument,
markDocumentViewed,
markDocumentsViewed,
mergeNotificationStatesIntoViewedDocumentKeys,
readViewedDocumentKeys,
resolveDocumentNewKey
} from '../utils/documentCenterNewState.js'
@@ -140,6 +148,17 @@ function refreshViewedDocumentKeys() {
viewedDocumentKeys.value = readViewedDocumentKeys()
}
async function refreshRemoteViewedDocumentKeys() {
try {
viewedDocumentKeys.value = mergeNotificationStatesIntoViewedDocumentKeys(
await fetchNotificationStates(),
readViewedDocumentKeys()
)
} catch {
refreshViewedDocumentKeys()
}
}
function attachViewedKeysListener() {
if (typeof window === 'undefined' || viewedKeysListenerAttached) {
return
@@ -150,8 +169,8 @@ function attachViewedKeysListener() {
}
async function readClaimList(fetcher) {
const result = await fetcher()
return Array.isArray(result) ? result : []
const result = await fetcher(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
return extractExpenseClaimItems(result)
}
export function useDocumentCenterInbox() {
@@ -206,8 +225,8 @@ export function useDocumentCenterInbox() {
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
})
await refreshRemoteViewedDocumentKeys()
lastRefreshAt = Date.now()
refreshViewedDocumentKeys()
return documentRows.value
})()

View File

@@ -29,7 +29,7 @@ export const navItems = [
id: 'documents',
label: '单据中心',
navHint: '统一查看申请、报销、审批与归档',
icon: icons.file,
icon: icons.documentCenter,
title: '单据中心',
desc: '统一查看申请、报销、审批与归档。'
},
@@ -37,7 +37,7 @@ export const navItems = [
id: 'receiptFolder',
label: '票据夹',
navHint: '存放已上传并识别的原始票据',
icon: icons.receipt,
icon: icons.receiptFolder,
title: '票据夹',
desc: '集中查看未关联和已关联票据,避免 OCR 后票据丢失。'
},

View File

@@ -54,6 +54,8 @@ const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
const ARCHIVED_STEP_LABEL = '已归档'
const REIMBURSEMENT_PROGRESS_LABELS = [
@@ -70,13 +72,17 @@ const APPLICATION_PROGRESS_LABELS = [
'创建申请',
'直属领导审批',
'预算管理者审批',
'审批完成'
'审批完成',
APPLICATION_LINK_STATUS_STEP_LABEL,
ARCHIVED_STEP_LABEL
]
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
'创建申请',
'直属领导审批',
'审批完成'
'审批完成',
APPLICATION_LINK_STATUS_STEP_LABEL,
ARCHIVED_STEP_LABEL
]
function parseNumber(value) {
@@ -425,6 +431,17 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
const rawNode = String(claim?.approval_stage || '').trim()
if (rawNode) {
if (
isApplicationDocument
&& approvalMeta.key === 'completed'
&& (
rawNode === '审批完成'
|| rawNode.includes('审批完成')
|| rawNode.includes('申请完成')
)
) {
return APPLICATION_LINK_STATUS_STEP_LABEL
}
if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
}
@@ -444,7 +461,7 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
if (approvalMeta.key === 'completed') {
const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
return isApplicationDocument ? '审批完成' : normalizedStatus === 'paid' ? '已付款' : '归档入账'
return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账'
}
return '直属领导审批'
@@ -578,9 +595,15 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') {
return 3
return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 4 : 3
}
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
return 4
}
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
return 3
}
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
return 3
}
@@ -602,6 +625,44 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
return 1
}
function isApplicationArchivedWorkflow(claim, workflowNode) {
const normalizedNode = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
return true
}
return getRiskFlags(claim).some((flag) => (
flag
&& typeof flag === 'object'
&& normalizeText(flag.source) === 'application_archive_sync'
))
}
function resolveApplicationLinkedReimbursementNo(claim) {
for (const flag of [...getRiskFlags(claim)].reverse()) {
if (!flag || typeof flag !== 'object') {
continue
}
const generatedNo = normalizeText(
flag.generated_draft_claim_no
|| flag.generatedDraftClaimNo
|| flag.reimbursement_claim_no
|| flag.reimbursementClaimNo
)
if (generatedNo) {
return generatedNo
}
}
return ''
}
function buildApplicationLinkStatusStepMeta(claim) {
const reimbursementNo = resolveApplicationLinkedReimbursementNo(claim)
const updatedAt = formatDateTime(claim?.updated_at)
return reimbursementNo
? buildProgressStepMeta(`关联中 ${reimbursementNo}`, updatedAt)
: buildProgressStepMeta('未关联', updatedAt)
}
function normalizeText(value) {
return String(value || '').trim()
}
@@ -1069,6 +1130,10 @@ function buildCompletedStepMeta(claim, label) {
return buildProgressStepMeta('待核对关联单据', createdAt)
}
if (stepLabel === APPLICATION_LINK_STATUS_STEP_LABEL) {
return buildApplicationLinkStatusStepMeta(claim)
}
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
const createdAt = formatDateTime(claim?.created_at)
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
@@ -1201,24 +1266,32 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
&& !hasMergedApplicationBudgetApproval
&& applicationRequiresBudgetReviewStep(claim, workflowNode)
)
const isApplicationDocument = documentTypeCode === DOCUMENT_TYPE_APPLICATION
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
const progressLabels =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
isApplicationDocument
? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交']
: hasMergedApplicationBudgetApproval
? ['创建申请', '直属领导审批', '审批完成']
? ['创建申请', '直属领导审批', '审批完成', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
: shouldShowApplicationBudgetStep
? APPLICATION_PROGRESS_LABELS
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
: REIMBURSEMENT_PROGRESS_LABELS
const applicationLinkIndex = progressLabels.indexOf(APPLICATION_LINK_STATUS_STEP_LABEL)
const applicationArchiveIndex = progressLabels.indexOf(ARCHIVED_STEP_LABEL)
const currentIndex =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
isApplicationDocument
? hasApplicationReturnStep
? 3
: Math.min(
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
Math.max(0, progressLabels.length - 1)
)
: applicationArchived && applicationArchiveIndex >= 0
? applicationArchiveIndex
: approvalMeta.key === 'completed' && applicationLinkIndex >= 0
? applicationLinkIndex
: Math.min(
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
Math.max(0, progressLabels.length - 1)
)
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
const currentTime =
approvalMeta.key === 'completed'
@@ -1233,7 +1306,7 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
return progressLabels.map((label, index) => {
const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
if (approvalMeta.key === 'completed') {
if (approvalMeta.key === 'completed' && (!isApplicationDocument || applicationArchived)) {
const stepMeta = buildCompletedStepMeta(claim, label)
return {
index: index + 1,
@@ -1264,6 +1337,20 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
}
if (index === currentIndex) {
if (isApplicationDocument && label === APPLICATION_LINK_STATUS_STEP_LABEL) {
const stepMeta = buildApplicationLinkStatusStepMeta(claim)
return {
index: index + 1,
label: displayLabel,
rawLabel: label,
time: stepMeta.time,
detail: stepMeta.detail,
title: stepMeta.title,
done: false,
active: true,
current: true
}
}
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
return {
index: index + 1,
@@ -1385,6 +1472,11 @@ export function mapExpenseClaimToRequest(claim) {
const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
const approvalMeta = resolveApprovalMeta(claim?.status)
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
const applicationLinkedReimbursementNo = isApplicationDocument ? resolveApplicationLinkedReimbursementNo(claim) : ''
const applicationLinkStatusText = applicationLinkedReimbursementNo
? `关联中 ${applicationLinkedReimbursementNo}`
: '未关联'
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
const riskMeta = buildRiskMeta(claim?.risk_flags_json)
const riskSummary = riskMeta.summary
@@ -1453,10 +1545,18 @@ export function mapExpenseClaimToRequest(claim) {
secondaryStatusValue: isApplicationDocument
? approvalMeta.key === 'supplement'
? '领导已退回,待重新提交'
: '已进入审批流程'
: applicationArchived
? '已归档'
: approvalMeta.key === 'completed'
? applicationLinkStatusText
: '已进入审批流程'
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
secondaryStatusTone: isApplicationDocument
? approvalMeta.key === 'supplement' ? 'warning' : 'success'
? approvalMeta.key === 'supplement'
? 'warning'
: approvalMeta.key === 'completed' && !applicationArchived && !applicationLinkedReimbursementNo
? 'warning'
: 'success'
: (invoiceCount > 0 ? 'success' : 'warning'),
riskSummary,
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),

View File

@@ -9,11 +9,13 @@ export const icons = {
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
documentCenter: iconPath('<path d="M8 4H6a2 2 0 0 0-2 2v16h16V6a2 2 0 0 0-2-2h-2"/><path d="M9 2h6v5H9z"/><path d="M8 12h.01"/><path d="M11 12h6"/><path d="M8 16h.01"/><path d="M11 16h6"/>'),
receipt: iconPath('<path d="M5 3v18l2-1 2 1 2-1 2 1 2-1 2 1 2-1V3z"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/>'),
receiptFolder: iconPath('<path d="M3 7h6l2 2h10v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7V5a2 2 0 0 1 2-2h4l2 2h4"/><path d="M8 13h8"/><path d="M8 16h6"/><path d="M8 19h4"/>'),
book: iconPath('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>'),
library: iconPath('<path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/><path d="M2 20h20"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
digitalEmployee: iconPath('<text x="12" y="12.2" text-anchor="middle" dominant-baseline="middle" font-size="16" font-weight="800" fill="currentColor" stroke="none" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" letter-spacing="-0.45">AI</text>'),
digitalEmployee: iconPath('<text x="12" y="12.2" text-anchor="middle" dominant-baseline="middle" font-size="16" font-weight="800" fill="currentColor" stroke="none" font-family="-apple-system, BlinkMacSystemFont, SF Pro Text, PingFang SC, Segoe UI, Arial, sans-serif" letter-spacing="-0.45">AI</text>'),
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),
audit: iconPath('<path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v7h7"/>'),
logs: iconPath('<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h10"/><path d="M18 17v4"/><path d="M16 19h4"/>'),

View File

@@ -1,5 +1,7 @@
import { apiRequest } from './api.js'
export const NOTIFICATION_STATE_BATCH_SIZE = 100
function normalizeStateItem(item) {
const notificationId = String(item?.notification_id || item?.notificationId || '').trim()
if (!notificationId) {
@@ -16,6 +18,17 @@ function normalizeStateItem(item) {
}
}
export function chunkNotificationStatePatches(states = [], batchSize = NOTIFICATION_STATE_BATCH_SIZE) {
const size = Math.max(1, Number(batchSize) || NOTIFICATION_STATE_BATCH_SIZE)
const chunks = []
for (let index = 0; index < states.length; index += size) {
chunks.push(states.slice(index, index + size))
}
return chunks
}
export async function fetchNotificationStates() {
const payload = await apiRequest('/notification-states')
return Array.isArray(payload?.states) ? payload.states : []
@@ -30,9 +43,14 @@ export async function patchNotificationStates(states = []) {
return []
}
const payload = await apiRequest('/notification-states', {
method: 'POST',
body: JSON.stringify({ states: normalizedStates })
})
return Array.isArray(payload?.states) ? payload.states : []
let latestStates = []
for (const batch of chunkNotificationStatePatches(normalizedStates)) {
const payload = await apiRequest('/notification-states', {
method: 'POST',
body: JSON.stringify({ states: batch })
})
latestStates = Array.isArray(payload?.states) ? payload.states : latestStates
}
return latestStates
}

View File

@@ -1,16 +1,44 @@
import { apiRequest } from './api.js'
const inflightOcrRequests = new Map()
function buildOcrRequestKey(files = []) {
return files
.map((file) => [
String(file?.name || ''),
String(file?.size || 0),
String(file?.lastModified || 0),
String(file?.receiptId || '')
].join(':'))
.join('|')
}
export function recognizeOcrFiles(files, options = {}) {
const requestKey = buildOcrRequestKey(files)
if (requestKey && inflightOcrRequests.has(requestKey)) {
return inflightOcrRequests.get(requestKey)
}
const formData = new FormData()
for (const file of files) {
formData.append('files', file)
formData.append('receipt_ids', String(file?.receiptId || ''))
}
return apiRequest('/ocr/recognize', {
const request = apiRequest('/ocr/recognize', {
method: 'POST',
body: formData,
contentType: null,
...options
})
if (!requestKey) {
return request
}
inflightOcrRequests.set(requestKey, request)
request.then(
() => inflightOcrRequests.delete(requestKey),
() => inflightOcrRequests.delete(requestKey)
)
return request
}

View File

@@ -1,5 +1,7 @@
import { apiRequest } from './api.js'
export const REIMBURSEMENT_LIST_PREVIEW_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
function buildListQuery(params = {}) {
const search = new URLSearchParams()
const page = params.page
@@ -17,6 +19,14 @@ function buildListQuery(params = {}) {
return query ? `?${query}` : ''
}
export function extractExpenseClaimItems(payload) {
if (Array.isArray(payload)) {
return payload
}
return Array.isArray(payload?.items) ? payload.items : []
}
export function fetchExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
}

View File

@@ -8,6 +8,22 @@ export function fetchStewardPlan(payload, options = {}) {
})
}
export function fetchStewardSlotDecision(payload, options = {}) {
return apiRequest('/steward/slot-decisions', {
method: 'POST',
body: JSON.stringify(payload),
...options
})
}
export function fetchStewardRuntimeDecision(payload, options = {}) {
return apiRequest('/steward/runtime-decisions', {
method: 'POST',
body: JSON.stringify(payload),
...options
})
}
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
const {
timeoutMs = 0,

View File

@@ -12,6 +12,16 @@ export function resolveDocumentNewKey(row) {
return id ? `${source}:${id}` : ''
}
export function resolveDocumentNotificationKey(row) {
const documentKey = String(row?.documentKey || '').trim()
return documentKey || resolveDocumentNewKey(row)
}
export function resolveDocumentNotificationId(row) {
const key = resolveDocumentNotificationKey(row)
return key ? `document:${key}` : ''
}
export function readViewedDocumentKeys(storage = getStorage()) {
if (!storage) {
return new Set()
@@ -66,6 +76,80 @@ export function countNewDocuments(rows, viewedKeys) {
return rows.filter((row) => isNewDocument(row, viewedKeys)).length
}
function resolveRemoteStateId(item) {
return String(item?.notification_id || item?.notificationId || '').trim()
}
function isRemoteStateViewed(item) {
return Boolean(item?.read_at || item?.readAt || item?.hidden_at || item?.hiddenAt)
}
export function mergeNotificationStatesIntoViewedDocumentKeys(states, viewedKeys, storage = getStorage()) {
const nextKeys = new Set(viewedKeys)
let changed = false
;(Array.isArray(states) ? states : []).forEach((item) => {
if (!isRemoteStateViewed(item)) {
return
}
const id = resolveRemoteStateId(item)
if (!id.startsWith('document:')) {
return
}
const key = id.slice('document:'.length).trim()
if (key && !nextKeys.has(key)) {
nextKeys.add(key)
changed = true
}
})
if (changed) {
writeViewedDocumentKeys(nextKeys, storage)
}
return nextKeys
}
export function buildDocumentViewedStatePatch(row) {
if (!isNewDocument(row, new Set())) {
return null
}
const notificationId = resolveDocumentNotificationId(row)
if (!notificationId) {
return null
}
return {
notification_id: notificationId,
read: true,
hidden: false,
context_json: {
kind: 'document',
source: String(row?.source || '').trim(),
target_type: 'documents-center'
}
}
}
export function buildDocumentsViewedStatePatches(rows, viewedKeys) {
const seenIds = new Set()
return (Array.isArray(rows) ? rows : [])
.filter((row) => isNewDocument(row, viewedKeys))
.map(buildDocumentViewedStatePatch)
.filter((patch) => {
if (!patch?.notification_id || seenIds.has(patch.notification_id)) {
return false
}
seenIds.add(patch.notification_id)
return true
})
}
export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
const key = resolveDocumentNewKey(row)
if (!key) {

View File

@@ -1,6 +1,7 @@
import { isApplicationRequestLike } from './documentClassification.js'
const ARCHIVED_CLAIM_STATUSES = new Set(['approved', 'completed', 'paid'])
const APPLICATION_ARCHIVE_STAGE = '申请归档'
function isArchivedRequestPayload(request) {
if (!request) {
@@ -9,6 +10,11 @@ function isArchivedRequestPayload(request) {
const normalizedStatus = String(request.status || '').trim().toLowerCase()
const stage = String(request.approval_stage || request.approvalStage || '').trim()
const isApplicationRequest = isApplicationRequestLike(request)
if (isApplicationRequest) {
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus) && stage === APPLICATION_ARCHIVE_STAGE
}
if (stage === '归档入账' || stage === '已付款' || stage === 'completed') {
return true
@@ -18,14 +24,6 @@ function isArchivedRequestPayload(request) {
return true
}
if (
isApplicationRequestLike(request)
&& ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
&& ['审批完成', '申请归档'].includes(stage)
) {
return true
}
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
&& (stage === '' || stage === '归档入账' || stage === '已付款' || stage === 'completed')
}

View File

@@ -125,6 +125,36 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
return resolveDaysFromDateRange(rangeText)
}
export function resolveApplicationDateRange(rangeText, daysText = '') {
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
const startDate = normalizeDateText(matchedDates[0] || '')
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
? explicitEndDate
: buildEndDateFromDays(startDate, daysText)
const endDate = inferredEndDate || explicitEndDate || startDate
const start = parseIsoDate(startDate)
const end = parseIsoDate(endDate)
if (!start || !end) {
return null
}
const orderedStart = start.getTime() <= end.getTime() ? start : end
const orderedEnd = start.getTime() <= end.getTime() ? end : start
return {
startDate: formatIsoDate(orderedStart),
endDate: formatIsoDate(orderedEnd),
startTime: orderedStart.getTime(),
endTime: orderedEnd.getTime()
}
}
export function applicationDateRangesOverlap(leftRange, rightRange) {
if (!leftRange || !rightRange) {
return false
}
return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
}
function resolvePreviewToday(options = {}) {
const explicitToday = String(options.today || options.currentDate || '').trim()
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
@@ -392,7 +422,7 @@ function normalizeApplicationTypeLabel(value, fallback = '') {
return `${label}申请`
}
function normalizeTransportModeOption(value, fallback = '') {
export function normalizeTransportModeOption(value, fallback = '') {
const text = String(value || '').trim()
if (/飞机|机票|航班/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'

View File

@@ -1,9 +1,16 @@
import { isApplicationRequestLike } from './documentClassification.js'
const APPLICATION_ARCHIVE_STAGE = '申请归档'
export function isArchivedExpenseClaim(claim) {
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
const status = String(claim?.status || '').trim().toLowerCase()
if (isApplicationRequestLike(claim)) {
return stage === APPLICATION_ARCHIVE_STAGE
&& ['approved', 'completed', 'paid'].includes(status)
}
if (stage === '归档入账' || stage === '已付款' || stage === 'completed' || stage.includes('归档')) {
return true
}
@@ -12,7 +19,7 @@ export function isArchivedExpenseClaim(claim) {
return false
}
if (isApplicationRequestLike(claim) && ['审批完成', '申请归档'].includes(stage)) {
if (stage === APPLICATION_ARCHIVE_STAGE) {
return true
}

View File

@@ -120,6 +120,22 @@ const ALLOWED_COLON_HEADING_TITLES = new Set([
'补充信息'
])
const BUSINESS_FIELD_LABELS = new Set([
'时间',
'地点',
'事由',
'金额',
'费用类型',
'报销类型',
'商户',
'商户/开票方',
'客户',
'客户/项目对象',
'附件',
'附件/凭证',
'出行方式'
])
function splitColonHeadingLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
@@ -142,7 +158,31 @@ function splitColonHeadingLine(line) {
return [rawLine]
}
return body ? [`### ${title}`, '', body] : [`### ${title}`]
return body ? [`### ${titleText}`, '', body] : [`### ${titleText}`]
}
function normalizeBusinessFieldLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (
!trimmed ||
trimmed.startsWith('|') ||
/^[-*+]\s/.test(trimmed) ||
/^#{1,6}\s/.test(trimmed)
) {
return rawLine
}
const match = trimmed.match(/^([^:\n]{1,16})[:]\s*(.+)$/u)
if (!match) {
return rawLine
}
const label = match[1].trim()
const value = match[2].trim()
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
return rawLine
}
return `- **${label}**${value}`
}
function normalizeColonHeadings(text) {
@@ -168,7 +208,7 @@ function normalizeColonHeadings(text) {
normalizedLines.push('')
}
}
normalizedLines.push(...nextLines)
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
})
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')

View File

@@ -1,16 +1,17 @@
import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw'
import approvalIcon from '../assets/workbench-icons/outline-approval.svg?raw'
import budgetIcon from '../assets/workbench-icons/outline-budget.svg?raw'
import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw'
import expenseApplicationIcon from '../assets/workbench-icons/outline-expense-application.svg?raw'
import financeAnalysisIcon from '../assets/workbench-icons/outline-finance-analysis.svg?raw'
import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw'
import policyIcon from '../assets/workbench-icons/outline-policy.svg?raw'
import reimbursementIcon from '../assets/workbench-icons/outline-reimbursement.svg?raw'
import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw'
import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw'
import usersIcon from '../assets/workbench-icons/outline-users.svg?raw'
import capExpenseImg from '../assets/images/cap-expense.png'
import capReimbImg from '../assets/images/cap-reimb.png'
import capBudgetImg from '../assets/images/cap-budget.png'
import capApprovalImg from '../assets/images/cap-approval.png'
import capAnalysisImg from '../assets/images/cap-analysis.png'
import capPolicyImg from '../assets/images/cap-policy.png'
function prepareHeroiconMarkup(svgRaw) {
return String(svgRaw || '')
.replace(/<svg\b([^>]*)>/i, '<svg class="workbench-heroicon"$1>')
@@ -18,13 +19,17 @@ function prepareHeroiconMarkup(svgRaw) {
.replace(/\saria-hidden="[^"]*"/g, '')
}
function prepareImageMarkup(src) {
return `<img src="${src}" class="workbench-image-icon" alt="" />`
}
export const workbenchIconMap = {
'expense-application': { markup: prepareHeroiconMarkup(expenseApplicationIcon), style: 'outline' },
'quick-reimbursement': { markup: prepareHeroiconMarkup(reimbursementIcon), style: 'outline' },
'budget-planning': { markup: prepareHeroiconMarkup(budgetIcon), style: 'outline' },
'quick-approval': { markup: prepareHeroiconMarkup(approvalIcon), style: 'outline' },
'finance-analysis': { markup: prepareHeroiconMarkup(financeAnalysisIcon), style: 'outline' },
'company-policy': { markup: prepareHeroiconMarkup(policyIcon), style: 'outline' },
'expense-application': { markup: prepareImageMarkup(capExpenseImg), style: 'image' },
'quick-reimbursement': { markup: prepareImageMarkup(capReimbImg), style: 'image' },
'budget-planning': { markup: prepareImageMarkup(capBudgetImg), style: 'image' },
'quick-approval': { markup: prepareImageMarkup(capApprovalImg), style: 'image' },
'finance-analysis': { markup: prepareImageMarkup(capAnalysisImg), style: 'image' },
'company-policy': { markup: prepareImageMarkup(capPolicyImg), style: 'image' },
hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' },
travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' },
receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' },

View File

@@ -1,3 +1,5 @@
import { isApplicationRequestLike } from './documentClassification.js'
function parseNumber(value) {
const nextValue = Number(value)
return Number.isFinite(nextValue) ? nextValue : 0
@@ -139,6 +141,23 @@ function resolveStatusTone(approvalKey) {
return 'muted'
}
function resolveDocumentTypeLabel(request, requestId, title) {
const explicitLabel = normalizeText(request?.documentTypeLabel || request?.document_type_label)
const normalizedRequestId = normalizeText(requestId).toUpperCase()
if (
isApplicationRequestLike(request)
|| explicitLabel.includes('申请')
|| title.includes('申请')
|| normalizedRequestId.startsWith('SQ')
|| normalizedRequestId.startsWith('CL')
) {
return '申请单'
}
return explicitLabel || '报销单'
}
function resolveTodoAction(request) {
const approvalKey = normalizeText(request?.approvalKey)
const status = normalizeText(request?.status || request?.approvalStatus)
@@ -245,8 +264,7 @@ function buildProgressItems(ownedRequests) {
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
const isApplication = title.includes('申请') || (requestId || '').toUpperCase().startsWith('SQ') || (requestId || '').toUpperCase().startsWith('CL')
const documentTypeLabel = isApplication ? '申请单' : '报销单'
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
return {
id: requestId,

View File

@@ -207,7 +207,7 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
@@ -241,7 +241,7 @@ const digitalEmployeeDetailOpen = ref(false)
const receiptFolderDetailOpen = ref(false)
const budgetDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(false)
const sidebarCollapsed = ref(true)
const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance')
let loginEntryTimer = null
@@ -393,4 +393,10 @@ onMounted(() => {
onBeforeUnmount(() => {
stopLoginEntryAnimation()
})
watch(activeView, (newView) => {
if (newView === 'workbench') {
sidebarCollapsed.value = true
}
}, { immediate: true })
</script>

View File

@@ -20,21 +20,36 @@
<i class="mdi mdi-magnify"></i>
<input v-model="budgetKeyword" type="search" placeholder="搜索预算编号、部门、编制人" />
</label>
<label class="budget-select-filter">
<span>年度</span>
<EnterpriseSelect v-model="filters.year" :options="yearOptions" />
</label>
<label class="budget-select-filter">
<span>季度</span>
<EnterpriseSelect v-model="filters.quarter" :options="quarterOptions" />
</label>
<label class="budget-select-filter">
<span>状态</span>
<EnterpriseSelect v-model="filters.status" :options="statusOptions" />
</label>
<DocumentDropdownFilter
id="year"
:active-filter-key="activeBudgetFilterKey"
:label="budgetYearFilterLabel"
title="年度"
:options="yearOptions"
:selected-value="filters.year"
@toggle="toggleBudgetFilter"
@select="selectBudgetFilter('year', $event)"
/>
<DocumentDropdownFilter
id="quarter"
:active-filter-key="activeBudgetFilterKey"
:label="budgetQuarterFilterLabel"
title="季度"
:options="quarterOptions"
:selected-value="filters.quarter"
@toggle="toggleBudgetFilter"
@select="selectBudgetFilter('quarter', $event)"
/>
<DocumentDropdownFilter
id="status"
:active-filter-key="activeBudgetFilterKey"
:label="budgetStatusFilterLabel"
title="状态"
:options="statusOptions"
:selected-value="filters.status"
@toggle="toggleBudgetFilter"
@select="selectBudgetFilter('status', $event)"
/>
</div>
<div class="document-actions">

View File

@@ -248,8 +248,25 @@ import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims,
fetchArchivedExpenseClaims
} from '../services/reimbursements.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import {
buildDocumentViewedStatePatch,
buildDocumentsViewedStatePatches,
countNewDocuments,
isNewDocument,
markDocumentViewed,
markDocumentsViewed,
mergeNotificationStatesIntoViewedDocumentKeys,
readDocumentScope,
readViewedDocumentKeys,
writeDocumentScope
} from '../utils/documentCenterNewState.js'
import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
@@ -860,9 +877,36 @@ function changePageSize(size) {
currentPage.value = 1
}
function applyRemoteViewedDocumentStates(states) {
viewedDocumentKeys.value = mergeNotificationStatesIntoViewedDocumentKeys(states, viewedDocumentKeys.value)
}
async function loadRemoteViewedDocumentKeys() {
try {
applyRemoteViewedDocumentStates(await fetchNotificationStates())
} catch {
// 接口不可用时保留本机已读缓存,避免影响单据中心主流程。
}
}
async function syncDocumentViewedPatches(patches) {
const normalizedPatches = (Array.isArray(patches) ? patches : [patches]).filter(Boolean)
if (!normalizedPatches.length) {
return
}
try {
applyRemoteViewedDocumentStates(await patchNotificationStates(normalizedPatches))
} catch {
// 本机状态已先落地;远端失败时等待下次操作或刷新重试。
}
}
function openDocument(row) {
writeDocumentScope(activeScopeTab.value, scopeTabs)
const viewedPatch = buildDocumentViewedStatePatch(row)
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
void syncDocumentViewedPatches([viewedPatch])
emit('open-document', row.rawRequest || row)
}
@@ -871,7 +915,9 @@ function markAllDocumentsRead() {
return
}
const viewedPatches = buildDocumentsViewedStatePatches(allReadableDocumentRows.value, viewedDocumentKeys.value)
viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value)
void syncDocumentViewedPatches(viewedPatches)
}
async function loadSupportingRows() {
@@ -879,30 +925,26 @@ async function loadSupportingRows() {
supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([
fetchApprovalExpenseClaims(),
fetchArchivedExpenseClaims()
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = excludeArchivedDocumentRows(
Array.isArray(approvalResult.value)
? approvalResult.value
extractExpenseClaimItems(approvalResult.value)
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
)
} else {
approvalRows.value = []
}
if (archiveResult.status === 'fulfilled') {
archiveRows.value = Array.isArray(archiveResult.value)
? archiveResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
.filter(Boolean)
: []
archiveRows.value = extractExpenseClaimItems(archiveResult.value)
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
.filter(Boolean)
} else {
archiveRows.value = []
supportingError.value = archiveResult.reason instanceof Error
@@ -915,6 +957,7 @@ async function loadSupportingRows() {
function reloadAll() {
emit('reload')
void loadRemoteViewedDocumentKeys()
void loadSupportingRows()
}
@@ -963,6 +1006,7 @@ watch(documentSummary, (summary) => {
}, { immediate: true })
onMounted(() => {
void loadRemoteViewedDocumentKeys()
void loadSupportingRows()
})
@@ -970,6 +1014,7 @@ watch(
() => props.refreshToken,
(token, previousToken) => {
if (token && token !== previousToken) {
void loadRemoteViewedDocumentKeys()
void loadSupportingRows()
}
}

View File

@@ -380,143 +380,38 @@
/>
</div>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'department' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'department'"
aria-haspopup="dialog"
@click="toggleFilterPopover('department')"
>
<span class="picker-label">{{ selectedDepartment || '组织部门' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'department'"
class="picker-popover"
role="dialog"
aria-label="选择组织部门"
>
<header>
<strong>选择组织部门</strong>
<button type="button" aria-label="关闭组织部门选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedDepartment }"
@click="selectFilter('department', '')"
>
全部部门
</button>
<button
v-for="department in departmentOptions"
:key="department"
type="button"
class="picker-option"
:class="{ active: selectedDepartment === department }"
@click="selectFilter('department', department)"
>
{{ department }}
</button>
</div>
</div>
</div>
<DocumentDropdownFilter
id="department"
:active-filter-key="activeFilterPopover"
:label="departmentFilterLabel"
title="选择组织部门"
:options="departmentFilterOptions"
:selected-value="selectedDepartment"
@toggle="toggleFilterPopover"
@select="selectFilter('department', $event)"
/>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'grade' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'grade'"
aria-haspopup="dialog"
@click="toggleFilterPopover('grade')"
>
<span class="picker-label">{{ selectedGrade || '职级' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'grade'"
class="picker-popover"
role="dialog"
aria-label="选择职级"
>
<header>
<strong>选择职级</strong>
<button type="button" aria-label="关闭职级选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedGrade }"
@click="selectFilter('grade', '')"
>
全部职级
</button>
<button
v-for="grade in gradeOptions"
:key="grade"
type="button"
class="picker-option"
:class="{ active: selectedGrade === grade }"
@click="selectFilter('grade', grade)"
>
{{ grade }}
</button>
</div>
</div>
</div>
<DocumentDropdownFilter
id="grade"
:active-filter-key="activeFilterPopover"
:label="gradeFilterLabel"
title="选择职级"
:options="gradeFilterOptions"
:selected-value="selectedGrade"
@toggle="toggleFilterPopover"
@select="selectFilter('grade', $event)"
/>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'role' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'role'"
aria-haspopup="dialog"
@click="toggleFilterPopover('role')"
>
<span class="picker-label">{{ selectedRole || '系统角色' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'role'"
class="picker-popover"
role="dialog"
aria-label="选择系统角色"
>
<header>
<strong>选择系统角色</strong>
<button type="button" aria-label="关闭系统角色选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedRole }"
@click="selectFilter('role', '')"
>
全部角色
</button>
<button
v-for="role in roleFilterOptions"
:key="role"
type="button"
class="picker-option"
:class="{ active: selectedRole === role }"
@click="selectFilter('role', role)"
>
{{ role }}
</button>
</div>
</div>
</div>
<DocumentDropdownFilter
id="role"
:active-filter-key="activeFilterPopover"
:label="roleFilterLabel"
title="选择系统角色"
:options="roleDropdownOptions"
:selected-value="selectedRole"
@toggle="toggleFilterPopover"
@select="selectFilter('role', $event)"
/>
</div>
<div class="toolbar-actions">

View File

@@ -211,4 +211,5 @@ import viewModel from './scripts/LogsView.js'
export default viewModel
</script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
<style scoped src="../assets/styles/views/logs-view.css"></style>

View File

@@ -23,48 +23,49 @@
<span>{{ knowledgeSyncButtonLabel }}</span>
</button>
</div>
</header>
<div class="library-body">
<aside class="folder-rail">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
</header>
<div class="library-body">
<aside class="folder-rail">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="(folder, index) in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
:style="{ '--delay': `${index * 40}ms` }"
@click="activeFolder = folder.name"
>
<i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
</aside>
<section class="document-area" :class="{ 'read-only': !isAdmin }">
<div
v-if="isAdmin"
class="upload-zone"
:class="{ busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<section class="document-area" :class="{ 'read-only': !isAdmin }">
<div
v-if="isAdmin"
class="upload-zone"
:class="{ busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<TableLoadingState
v-if="loading && !visibleDocuments.length"
title="知识库文件同步中"
@@ -74,26 +75,27 @@
/>
<table class="knowledge-document-table">
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>归纳时间</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
</tr>
</thead>
<tbody>
<tr
v-for="(doc, index) in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
:style="{ '--delay': `${index * 50}ms` }"
@click="selectDocument(doc.id)"
>
<td data-label="文件名称">
<span class="file-name">
<i :class="doc.icon"></i>

View File

@@ -20,9 +20,21 @@
<i class="mdi mdi-magnify"></i>
<input v-model="keyword" type="search" placeholder="搜索文件名、票据类型、金额、关联单号..." />
</div>
<button class="filter-btn" type="button" @click="reloadReceipts">
<i class="mdi mdi-refresh"></i>
<span>刷新</span>
<DocumentDropdownFilter
v-for="control in receiptFilterControls"
:id="control.key"
:key="control.key"
:active-filter-key="openReceiptFilterKey"
:label="resolveReceiptFilterLabel(control)"
:title="control.label"
:options="control.options"
:selected-value="receiptFilters[control.key]"
@toggle="toggleReceiptFilter"
@select="selectReceiptFilter(control.key, $event)"
/>
<button v-if="hasActiveReceiptFilters" class="filter-btn clear-filter-btn" type="button" @click="clearReceiptFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
</div>
@@ -349,6 +361,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import DocumentDropdownFilter from '../components/shared/DocumentDropdownFilter.vue'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
@@ -365,6 +378,7 @@ import {
} from '../services/receiptFolder.js'
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js'
const NEW_CLAIM_VALUE = '__new_claim__'
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
@@ -417,19 +431,17 @@ const activeRows = computed(() => {
return receipts.value
})
const showStatusColumn = computed(() => activeStatus.value !== 'linked')
const filteredRows = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
if (!normalized) return activeRows.value
return activeRows.value.filter((item) => [
item.file_name,
item.document_type_label,
item.scene_label,
item.summary,
item.amount,
item.document_date,
item.linked_claim_no
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
const {
filteredRows,
hasActiveReceiptFilters,
openReceiptFilterKey,
receiptFilterControls,
receiptFilters,
clearReceiptFilters,
resolveReceiptFilterLabel,
selectReceiptFilter,
toggleReceiptFilter
} = createReceiptFolderListFilterModel({ receipts, activeRows, keyword })
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(() => {
@@ -531,7 +543,15 @@ const associatePrimaryLabel = computed(() => {
return associateStep.value === 1 ? '下一步' : '进入关联对话'
})
watch([activeStatus, keyword, pageSize], () => {
watch([
activeStatus,
keyword,
pageSize,
() => receiptFilters.documentType,
() => receiptFilters.scene,
() => receiptFilters.month,
() => receiptFilters.quality
], () => {
currentPage.value = 1
})

View File

@@ -90,14 +90,30 @@
</div>
</div>
<div ref="messageListRef" class="message-list" aria-live="polite">
<transition-group tag="div" name="message-row-reveal" ref="messageListRef" class="message-list" aria-live="polite">
<div
v-if="showStewardInitialRecognition"
class="steward-initial-recognition"
role="status"
aria-live="polite"
key="initial-recognition"
>
<div class="steward-initial-recognition-icon">
<i class="mdi mdi-brain"></i>
</div>
<div class="steward-initial-recognition-copy">
<strong>小财管家正在识别意图</strong>
<p>我正在读取你的输入准备拆解申请报销和附件任务</p>
</div>
</div>
<TravelReimbursementMessageItem
v-for="message in messages"
:key="message.id"
:message="message"
:ui="messageItemUi"
/>
</div>
</transition-group>
<form class="composer" @submit.prevent="submitComposer">
<input

View File

@@ -2,10 +2,10 @@ import { computed, onMounted, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.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'
import { fetchEmployeeMeta } from '../../services/employees.js'
@@ -47,6 +47,10 @@ function mapOptions(values, suffix = '') {
}))
}
function resolveOptionLabel(options, value, fallback) {
return (Array.isArray(options) ? options : []).find((option) => option.value === value)?.label || fallback
}
function resolveBudgetUpdatedAt(row) {
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
}
@@ -99,8 +103,8 @@ export default {
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
DocumentDropdownFilter,
EnterprisePagination,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
TableEmptyState,
@@ -116,6 +120,7 @@ export default {
const budgetLoading = ref(true)
const budgetError = ref('')
const selectedBudgetId = ref('')
const activeBudgetFilterKey = ref('')
const filters = ref({
year: '2026',
quarter: 'Q1',
@@ -158,6 +163,9 @@ export default {
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
)
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
const budgetYearFilterLabel = computed(() => resolveOptionLabel(yearOptions, filters.value.year, '年度'))
const budgetQuarterFilterLabel = computed(() => resolveOptionLabel(quarterOptions, filters.value.quarter, '季度'))
const budgetStatusFilterLabel = computed(() => resolveOptionLabel(statusOptions.value, filters.value.status, '状态'))
const filteredBudgetRows = computed(() =>
activeScopeRows.value
@@ -322,6 +330,15 @@ export default {
budgetPage.value = 1
}
function toggleBudgetFilter(key) {
activeBudgetFilterKey.value = activeBudgetFilterKey.value === key ? '' : key
}
function selectBudgetFilter(key, value) {
filters.value[key] = value
activeBudgetFilterKey.value = ''
}
function resolveScopedDepartments(options) {
if (!isDepartmentBudgetMonitor.value) return options
@@ -419,6 +436,7 @@ export default {
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
activeBudgetFilterKey,
activeBudgetScope,
budgetError,
budgetKeyword,
@@ -427,6 +445,9 @@ export default {
budgetPageSize,
budgetPageSizeOptions,
budgetScopeTabs,
budgetQuarterFilterLabel,
budgetStatusFilterLabel,
budgetYearFilterLabel,
backToList,
canAuditBudgetDrafts,
canEditBudget,
@@ -447,8 +468,10 @@ export default {
showEmpty,
showTable,
statusOptions,
selectBudgetFilter,
totalBudgetPages,
totalBudgetRows,
toggleBudgetFilter,
visibleBudgetRows,
yearOptions
}

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
@@ -449,10 +450,18 @@ function buildEmployeeSummary(employees) {
}
}
function mapSimpleFilterOptions(values, allLabel) {
return [
{ label: allLabel, value: '' },
...values.map((value) => ({ label: value, value }))
]
}
export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
DocumentDropdownFilter,
EnterprisePagination,
EnterpriseSelect,
TableLoadingState,
@@ -559,6 +568,12 @@ export default {
)
)
)
const departmentFilterOptions = computed(() => mapSimpleFilterOptions(departmentOptions.value, '全部部门'))
const gradeFilterOptions = computed(() => mapSimpleFilterOptions(gradeOptions.value, '全部职级'))
const roleDropdownOptions = computed(() => mapSimpleFilterOptions(roleFilterOptions.value, '全部角色'))
const departmentFilterLabel = computed(() => selectedDepartment.value || '组织部门')
const gradeFilterLabel = computed(() => selectedGrade.value || '职级')
const roleFilterLabel = computed(() => selectedRole.value || '系统角色')
const managerOptions = computed(() => {
const currentId = selectedEmployee.value?.id
@@ -1440,6 +1455,12 @@ export default {
selectedDepartment,
selectedGrade,
selectedRole,
departmentFilterLabel,
departmentFilterOptions,
gradeFilterLabel,
gradeFilterOptions,
roleDropdownOptions,
roleFilterLabel,
activeFilterPopover,
currentPage,
pageSize,

File diff suppressed because it is too large Load Diff

View File

@@ -203,7 +203,7 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) {
const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword
return Boolean(
budgetContext ||
(isBudgetContext && budgetContext) ||
(
text &&
(isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent)

View File

@@ -0,0 +1,179 @@
import { computed, reactive, ref } from 'vue'
export const RECEIPT_FILTER_ALL = 'all'
const QUALITY_OPTIONS = [
{ value: RECEIPT_FILTER_ALL, label: '全部置信度' },
{ value: 'high', label: '高置信度' },
{ value: 'medium', label: '中等置信度' },
{ value: 'low', label: '低置信度' },
{ value: 'missing', label: '待确认' }
]
function normalizeText(value) {
return String(value ?? '').trim()
}
function getFilterValue(filters, key) {
return normalizeText(filters?.[key]) || RECEIPT_FILTER_ALL
}
function buildUniqueOptions(rows, valueKey, labelKey, allLabel) {
const seen = new Map()
for (const row of Array.isArray(rows) ? rows : []) {
const value = normalizeText(row?.[valueKey])
if (!value || seen.has(value)) continue
seen.set(value, normalizeText(row?.[labelKey]) || value)
}
return [
{ value: RECEIPT_FILTER_ALL, label: allLabel },
...Array.from(seen.entries())
.map(([value, label]) => ({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, 'zh-Hans-CN'))
]
}
function resolveReceiptMonth(row) {
const raw = normalizeText(row?.document_date) || normalizeText(row?.uploaded_at)
const match = raw.match(/^(\d{4})[-/年]?(\d{1,2})/)
if (!match) return ''
return `${match[1]}-${String(match[2]).padStart(2, '0')}`
}
function buildMonthOptions(rows) {
const months = new Set((Array.isArray(rows) ? rows : []).map(resolveReceiptMonth).filter(Boolean))
return [
{ value: RECEIPT_FILTER_ALL, label: '全部月份' },
...Array.from(months)
.sort((left, right) => right.localeCompare(left))
.map((value) => ({ value, label: `${value.replace('-', '年')}` }))
]
}
function resolveScore(row) {
const score = Number(row?.avg_score || 0)
return Number.isFinite(score) ? score : 0
}
function matchesQuality(row, quality) {
if (quality === RECEIPT_FILTER_ALL) return true
const score = resolveScore(row)
if (quality === 'missing') return score <= 0
if (quality === 'high') return score >= 0.9
if (quality === 'medium') return score >= 0.75 && score < 0.9
if (quality === 'low') return score > 0 && score < 0.75
return true
}
export function buildReceiptFilterControls(rows, filters) {
return [
{
key: 'documentType',
label: '票据类型',
options: buildUniqueOptions(rows, 'document_type', 'document_type_label', '全部类型')
},
{
key: 'scene',
label: '费用场景',
options: buildUniqueOptions(rows, 'scene_code', 'scene_label', '全部场景')
},
{
key: 'month',
label: '票据月份',
options: buildMonthOptions(rows)
},
{
key: 'quality',
label: '置信度',
options: QUALITY_OPTIONS
}
].map((control) => ({
...control,
value: getFilterValue(filters, control.key)
}))
}
export function applyReceiptListFilters(rows, filters) {
const documentType = getFilterValue(filters, 'documentType')
const scene = getFilterValue(filters, 'scene')
const month = getFilterValue(filters, 'month')
const quality = getFilterValue(filters, 'quality')
return (Array.isArray(rows) ? rows : []).filter((row) => (
(documentType === RECEIPT_FILTER_ALL || normalizeText(row?.document_type) === documentType)
&& (scene === RECEIPT_FILTER_ALL || normalizeText(row?.scene_code) === scene)
&& (month === RECEIPT_FILTER_ALL || resolveReceiptMonth(row) === month)
&& matchesQuality(row, quality)
))
}
export function buildReceiptFilterTokens(controls, filters) {
return (Array.isArray(controls) ? controls : [])
.map((control) => {
const value = getFilterValue(filters, control.key)
if (value === RECEIPT_FILTER_ALL) return ''
const option = control.options.find((item) => item.value === value)
return `${control.label}${option?.label || value}`
})
.filter(Boolean)
}
export function createReceiptFolderListFilterModel({ receipts, activeRows, keyword }) {
const openReceiptFilterKey = ref('')
const receiptFilters = reactive({
documentType: RECEIPT_FILTER_ALL,
scene: RECEIPT_FILTER_ALL,
month: RECEIPT_FILTER_ALL,
quality: RECEIPT_FILTER_ALL
})
const receiptFilterControls = computed(() => buildReceiptFilterControls(receipts.value, receiptFilters))
const hasActiveReceiptFilters = computed(() => Object.values(receiptFilters).some((value) => value !== RECEIPT_FILTER_ALL))
const filteredRows = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
const filtered = applyReceiptListFilters(activeRows.value, receiptFilters)
if (!normalized) return filtered
return filtered.filter((item) => [
item.file_name,
item.document_type_label,
item.scene_label,
item.summary,
item.amount,
item.document_date,
item.linked_claim_no
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
function toggleReceiptFilter(key) {
openReceiptFilterKey.value = openReceiptFilterKey.value === key ? '' : key
}
function selectReceiptFilter(key, value) {
receiptFilters[key] = value
openReceiptFilterKey.value = ''
}
function resolveReceiptFilterLabel(control) {
return control.options.find((option) => option.value === receiptFilters[control.key])?.label || control.label
}
function clearReceiptFilters() {
receiptFilters.documentType = RECEIPT_FILTER_ALL
receiptFilters.scene = RECEIPT_FILTER_ALL
receiptFilters.month = RECEIPT_FILTER_ALL
receiptFilters.quality = RECEIPT_FILTER_ALL
openReceiptFilterKey.value = ''
}
return {
filteredRows,
hasActiveReceiptFilters,
openReceiptFilterKey,
receiptFilterControls,
receiptFilters,
clearReceiptFilters,
resolveReceiptFilterLabel,
selectReceiptFilter,
toggleReceiptFilter
}
}

View File

@@ -0,0 +1,131 @@
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
const APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP = {
applicationType: 'expense_type',
time: 'time_range',
location: 'location',
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
}
const APPLICATION_PREVIEW_FIELD_LABEL_MAP = {
applicationType: '申请类型',
time: '时间',
location: '地点',
reason: '事由',
amount: '金额',
transportMode: '出行方式',
department: '所属部门',
applicant: '申请人',
grade: '职级'
}
function compactValue(value = '') {
return String(value || '').trim()
}
function resolveStewardCurrentTask(continuation = null) {
const task = continuation?.currentTask || continuation?.current_task || null
return task && typeof task === 'object' ? task : null
}
function resolveTaskOntologyFields(task = null) {
const fields = task?.ontology_fields || task?.ontologyFields || {}
return fields && typeof fields === 'object' ? fields : {}
}
function resolveFieldValue(...candidates) {
for (const candidate of candidates) {
const value = compactValue(candidate)
if (value && !['待补充', '待测算', '未知'].includes(value)) {
return value
}
}
return ''
}
function buildUpdatedTask(task = null, fieldKey = '', value = '') {
if (!task || typeof task !== 'object') {
return null
}
const canonicalKey = APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP[fieldKey] || ''
if (!canonicalKey) {
return { ...task }
}
const ontologyFields = {
...resolveTaskOntologyFields(task),
[canonicalKey]: value
}
const sourceMissingFields = Array.isArray(task.missing_fields)
? task.missing_fields
: Array.isArray(task.missingFields)
? task.missingFields
: []
return {
...task,
ontology_fields: ontologyFields,
missing_fields: sourceMissingFields.filter((field) => compactValue(field) !== canonicalKey)
}
}
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
const source = continuation && typeof continuation === 'object' ? continuation : {}
const currentTask = resolveStewardCurrentTask(source)
const updatedTask = buildUpdatedTask(currentTask, fieldKey, value)
if (!updatedTask) {
return source
}
return {
...source,
currentTask: updatedTask
}
}
export function buildStewardFieldCompletionRawText({
preview = {},
fieldKey = '',
fieldLabel = '',
value = '',
continuation = null
} = {}) {
const normalizedPreview = normalizeApplicationPreview(preview)
const fields = normalizedPreview.fields || {}
const currentTask = resolveStewardCurrentTask(continuation)
const ontologyFields = resolveTaskOntologyFields(currentTask)
const selectedLabel = compactValue(fieldLabel) || APPLICATION_PREVIEW_FIELD_LABEL_MAP[fieldKey] || '补充项'
const selectedValue = compactValue(value)
const transportMode = fieldKey === 'transportMode'
? selectedValue
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
const knownLines = [
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')],
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
['天数', resolveFieldValue(fields.days)],
['出行方式', transportMode]
]
.filter(([, fieldValue]) => fieldValue)
.map(([label, fieldValue]) => `${label}${fieldValue}`)
return [
'小财管家继续执行申请单字段补齐。',
`用户已补充:${selectedLabel}${selectedValue}`,
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
'',
'已识别信息:',
...knownLines,
'',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
].filter((line) => line !== '').join('\n')
}

View File

@@ -79,6 +79,25 @@ const FIELD_ALIASES = {
application_transport_mode: 'transport_mode'
}
const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'employee_name',
'department_name'
])
const FIELD_VALUE_DISPLAY_CONFIG = {
expense_type: {
travel: '差旅',
business_entertainment: '业务招待',
transportation: '交通费',
traffic: '交通费',
accommodation: '住宿费',
meal: '餐饮费'
}
}
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
const safeFiles = Array.isArray(files) ? files : []
return {
@@ -123,9 +142,10 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
tasks: Array.isArray(rawPlan.tasks)
? rawPlan.tasks.map((item) => {
const taskType = String(item.task_type || item.taskType || '')
const missingFields = Array.isArray(item.missing_fields || item.missingFields)
const rawMissingFields = Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields
: []
const missingFields = filterStewardBlockingMissingFields(rawMissingFields, taskType)
return {
taskId: String(item.task_id || item.taskId || ''),
taskType,
@@ -188,7 +208,7 @@ export function buildStewardPlanMessageText(plan) {
}
export function buildStewardFieldItems(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const safeFields = filterStewardBlockingMissingFields(fields, taskType)
const seen = new Set()
return safeFields
.map((field) => normalizeFieldKey(field))
@@ -202,18 +222,44 @@ export function buildStewardFieldItems(fields = [], taskType = '') {
.map((field) => resolveFieldDisplay(field, taskType))
}
export function formatStewardMissingFieldList(fields = [], taskType = '') {
export function formatStewardMissingFieldList(fields = [], taskType = '', options = {}) {
const includeHints = options.includeHints !== false
return buildStewardFieldItems(fields, taskType)
.map((item) => item.hint ? `${item.label}${item.hint}` : item.label)
.map((item) => includeHints && item.hint ? `${item.label}${item.hint}` : item.label)
.join('、')
}
export function filterStewardBlockingMissingFields(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const seen = new Set()
if (taskType !== 'expense_application') {
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
}
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field) || APPLICATION_NON_BLOCKING_MISSING_FIELDS.has(field)) {
return false
}
seen.add(field)
return true
})
}
export function formatStewardOntologyFields(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return `${field.label}${value}`
return `${field.label}${formatStewardFieldDisplayValue(field.key, value)}`
})
.join('')
}
@@ -246,6 +292,7 @@ export function buildStewardSuggestedActions(plan) {
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
steward_plan_id: normalized.planId,
steward_next_task_id: task?.taskId || '',
steward_current_task: buildStewardTaskPayload(task),
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
}
@@ -447,7 +494,11 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
}
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType)
const missingFields = formatStewardMissingFieldList(
task.missingFields || [],
task.taskType,
{ includeHints: false }
)
const lines = [
actionType === 'confirm_create_application'
? `小财管家已完成意图识别,请先创建申请单:${task.title || task.taskTypeLabel}`
@@ -458,8 +509,12 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
? missingFields
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: missingFields
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) {
@@ -495,6 +550,12 @@ function resolveFieldDisplay(field, taskType = '') {
}
}
function formatStewardFieldDisplayValue(field, value) {
const key = normalizeFieldKey(field)
const normalizedValue = String(value || '').trim()
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
}
function buildRemainingTaskText(normalized, currentTaskId) {
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
if (!remainingTasks.length) {
@@ -512,13 +573,20 @@ function buildRemainingTaskText(normalized, currentTaskId) {
function buildRemainingTaskPayload(normalized, currentTaskId) {
return normalized.tasks
.filter((task) => task.taskId !== currentTaskId)
.map((task) => ({
task_id: task.taskId,
task_type: task.taskType,
title: task.title,
summary: task.summary,
assigned_agent: task.assignedAgent,
ontology_fields: task.ontologyFields || {},
missing_fields: task.missingFields || []
}))
.map((task) => buildStewardTaskPayload(task))
}
function buildStewardTaskPayload(task) {
if (!task) {
return null
}
return {
task_id: task.taskId || task.task_id || '',
task_type: task.taskType || task.task_type || '',
title: task.title || '',
summary: task.summary || '',
assigned_agent: task.assignedAgent || task.assigned_agent || '',
ontology_fields: task.ontologyFields || task.ontology_fields || {},
missing_fields: task.missingFields || task.missing_fields || []
}
}

View File

@@ -6,8 +6,10 @@ import {
} from './stewardPlanModel.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
export function useStewardPlanFlow({
activeSessionType,
@@ -174,7 +176,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -269,7 +271,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
index = Math.min(chars.length, index + STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE)
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
}

View File

@@ -13,7 +13,10 @@ import {
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
applicationDateRangesOverlap,
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
@@ -21,16 +24,275 @@ import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
import { fetchStewardSlotDecision } from '../../services/steward.js'
import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
applicationType: 'expense_type',
time: 'time_range',
location: 'location',
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
}
const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = {
费用类型: 'expense_type',
申请类型: 'expense_type',
发生时间: 'time_range',
出发时间: 'time_range',
申请时间: 'time_range',
地点: 'location',
事由: 'reason',
金额: 'amount',
系统预估费用: 'amount',
出行方式: 'transport_mode',
附件: 'attachments',
'附件/凭证': 'attachments',
商户: 'merchant_name',
'商户/开票方': 'merchant_name',
客户: 'customer_name',
客户或项目对象: 'customer_name'
}
const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
expense_type: 'applicationType',
time_range: 'time',
location: 'location',
reason: 'reason',
amount: 'amount',
transport_mode: 'transportMode',
department_name: 'department',
employee_name: 'applicant',
employee_grade: 'grade'
}
const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
expense_type: '费用类型',
time_range: '时间',
location: '地点',
reason: '事由',
amount: '金额',
transport_mode: '出行方式',
attachments: '附件/凭证',
customer_name: '客户或项目对象',
merchant_name: '商户/开票方',
department_name: '所属部门',
employee_name: '申请人',
employee_grade: '职级'
}
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'department_name',
'employee_name'
])
const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([
'cancelled',
'canceled',
'void',
'voided',
'deleted',
'已取消',
'已作废',
'作废',
'已删除'
])
function normalizeClaimListPayload(payload) {
if (Array.isArray(payload)) {
return payload
}
return Array.isArray(payload?.items) ? payload.items : []
}
function normalizeClaimRiskFlags(claim) {
const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || []
if (Array.isArray(flags)) {
return flags
}
return flags && typeof flags === 'object' ? [flags] : []
}
function extractApplicationDetailFromClaim(claim) {
return normalizeClaimRiskFlags(claim).reduce((found, item) => {
if (found || !item || typeof item !== 'object') {
return found
}
const detail = item.application_detail || item.applicationDetail
return detail && typeof detail === 'object' ? detail : null
}, null)
}
function isApplicationClaimRecord(claim) {
const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase()
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
return (
expenseType === 'application' ||
expenseType === 'expense_application' ||
expenseType.endsWith('_application') ||
claimNo.startsWith('AP-') ||
claimNo.startsWith('APP-') ||
Boolean(extractApplicationDetailFromClaim(claim))
)
}
function normalizeApplicationExpenseType(value) {
const text = String(value || '').trim().toLowerCase()
if (!text) {
return ''
}
if (text === 'travel_application' || /差旅|出差/.test(text)) {
return 'travel_application'
}
if (text === 'purchase_application' || /采购/.test(text)) {
return 'purchase_application'
}
if (text === 'meeting_application' || /会务|会议/.test(text)) {
return 'meeting_application'
}
if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) {
return text === 'application' ? 'expense_application' : text
}
return 'expense_application'
}
function resolveClaimApplicationExpenseType(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return normalizeApplicationExpenseType(
claim?.expense_type ||
claim?.expenseType ||
detail.application_type ||
detail.applicationType ||
''
)
}
function isIgnoredApplicationDuplicateStatus(status) {
return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase())
}
function resolveClaimApplicationDateRange(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return (
resolveApplicationDateRange(
detail.time ||
detail.time_range ||
detail.timeRange ||
detail.application_time ||
detail.applicationTime ||
detail.application_business_time ||
detail.applicationBusinessTime ||
detail.application_date ||
detail.applicationDate,
detail.days || detail.application_days || detail.applicationDays
) ||
resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '')
)
}
function formatApplicationDateRangeLabel(range) {
if (!range?.startDate) {
return '待确认'
}
return range.startDate === range.endDate ? range.startDate : `${range.startDate}${range.endDate}`
}
function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
const preview = normalizeApplicationPreview(applicationPreview)
const fields = preview.fields || {}
const currentRange = resolveApplicationDateRange(fields.time, fields.days)
if (!currentRange) {
return null
}
const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType)
const claims = normalizeClaimListPayload(claimsPayload)
for (const claim of claims) {
if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) {
continue
}
const existingExpenseType = resolveClaimApplicationExpenseType(claim)
if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) {
continue
}
const existingRange = resolveClaimApplicationDateRange(claim)
if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) {
continue
}
return {
claim,
currentRange,
existingRange,
claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(),
claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(),
status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(),
reason: String(claim?.reason || '').trim(),
location: String(claim?.location || '').trim()
}
}
return null
}
function buildApplicationDateConflictMessage(conflict) {
const claimNo = conflict?.claimNo || '已有申请'
return [
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
'',
'已有申请:',
`- **单号**${claimNo}`,
`- **申请时间**${formatApplicationDateRangeLabel(conflict?.existingRange)}`,
conflict?.location ? `- **地点**${conflict.location}` : '',
conflict?.reason ? `- **事由**${conflict.reason}` : '',
`- **当前节点**${conflict?.status || '处理中'}`,
'',
`本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`,
'',
'请先查看已有申请,或修改本次出差时间后再继续。'
].filter(Boolean).join('\n')
}
function buildApplicationDateConflictActions(conflict) {
const actions = []
if (conflict?.claimId) {
actions.push({
action_type: 'open_application_detail',
label: '查看已有申请',
description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。',
icon: 'mdi mdi-file-search-outline',
payload: {
claim_id: conflict.claimId,
claim_no: conflict.claimNo
}
})
}
actions.push({
action_type: 'prefill_composer',
label: '修改出差时间',
description: '在输入框中补充新的出差日期后继续。',
icon: 'mdi mdi-calendar-edit-outline',
payload: {
prompt_prefill: '修改出差时间为:'
}
})
return actions
}
export function useTravelReimbursementSubmitComposer(ctx) {
const {
MAX_ATTACHMENTS,
@@ -145,8 +407,21 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
}
function isBlockingApplicationOntologyField(key = '') {
const normalizedKey = String(key || '').trim()
return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey))
}
function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) {
return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => {
const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || ''
return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey)
})
}
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
const normalized = normalizeApplicationPreview(preview)
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
if (!missingFields.includes('出行方式')) {
return []
}
@@ -158,77 +433,298 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label: mode,
description: `选择${mode}作为本次出行方式,并同步费用测算`,
description: `选择${mode}后,由小财管家继续查询票价并测算费用`,
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
payload: {
field_key: 'transportMode',
field_label: '出行方式',
value: mode
value: mode,
applicationPreview: normalized,
steward_delegated_field_completion: true
}
}))
}
function resolveStewardContinuationCurrentTask(continuation = null) {
const task = continuation?.currentTask || continuation?.current_task || null
return task && typeof task === 'object' ? task : null
}
function normalizeCanonicalFieldList(fields = []) {
const normalized = []
if (!Array.isArray(fields)) {
return normalized
}
fields.forEach((field) => {
const key = String(field || '').trim()
if (key && !normalized.includes(key)) {
normalized.push(key)
}
})
return normalized
}
function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) {
const normalizedPreview = normalizeApplicationPreview(preview)
const previewFields = normalizedPreview.fields || {}
const task = resolveStewardContinuationCurrentTask(continuation)
const taskFields = task?.ontology_fields || task?.ontologyFields || {}
const fields = {}
Object.entries(taskFields || {}).forEach(([key, value]) => {
const normalizedKey = String(key || '').trim()
const normalizedValue = String(value || '').trim()
if (normalizedKey && normalizedValue) {
fields[normalizedKey] = normalizedValue
}
})
Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => {
const value = String(previewFields[previewKey] || '').trim()
if (value && value !== '待补充' && !fields[ontologyKey]) {
fields[ontologyKey] = value
}
})
return fields
}
function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) {
const task = resolveStewardContinuationCurrentTask(continuation)
const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || [])
.filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim())
if (taskMissingFields.length) {
return taskMissingFields
}
return resolveApplicationPreviewMissingFieldsForSteward(preview)
.map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '')
.filter((key, index, list) =>
key &&
isBlockingApplicationOntologyField(key) &&
!String(ontologyFields[key] || '').trim() &&
list.indexOf(key) === index
)
}
async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) {
const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation)
const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields)
try {
return await fetchStewardSlotDecision({
task_type: 'expense_application',
user_message: String(rawText || '').trim(),
ontology_fields: ontologyFields,
missing_fields: missingFields,
task_context: {
steward_continuation: continuation || null,
application_preview: normalizeApplicationPreview(preview)
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。'
})
} catch (error) {
console.warn('Steward slot decision failed:', error)
return null
}
}
function formatStewardDecisionUserText(text = '') {
let formatted = String(text || '').trim()
Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => {
const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
formatted = formatted
.replace(new RegExp(`\\s*${escapedKey}\\s*`, 'g'), '')
.replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '')
.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label)
})
return formatted.replace(/\s{2,}/g, ' ').trim()
}
function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return fallbackText
}
const question = formatStewardDecisionUserText(decision.question || '')
const rationale = formatStewardDecisionUserText(decision.rationale || '')
const parts = [
'我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。',
'',
rationale ? `**原因是:${rationale}**` : '',
'',
question || buildStewardApplicationPreviewMessage(preview, fallbackText)
].filter((item) => item !== '')
return parts.join('\n')
}
function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return []
}
const normalizedPreview = normalizeApplicationPreview(preview)
const iconMap = {
火车: 'mdi mdi-train',
飞机: 'mdi mdi-airplane',
轮船: 'mdi mdi-ferry'
}
const actions = Array.isArray(decision.options) ? decision.options : []
return actions.map((option) => {
const canonicalField = String(option?.field_key || option?.fieldKey || '').trim()
if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) {
return null
}
const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField
const value = String(option?.value || option?.label || '').trim()
const label = String(option?.label || value).trim()
const normalizedValue = fieldKey === 'transportMode'
? normalizeTransportModeOption(value || label, '')
: value
if (!fieldKey || !value || !label) {
return null
}
if (fieldKey === 'transportMode' && !normalizedValue) {
return null
}
return {
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label,
description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`,
icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select',
payload: {
field_key: fieldKey,
field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label,
value: normalizedValue,
applicationPreview: normalizedPreview,
steward_delegated_field_completion: true
}
}
}).filter(Boolean)
}
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'',
`**还需要你补充:${missingFields.join('、')}。**`,
'',
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
].join('\n')
}
function shouldPauseStewardApplicationPreview(preview = {}) {
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
}
function extractStewardCarryLine(text = '', label = '') {
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[:]([^\\n]+)`, 'u'))
return match ? match[1].trim() : ''
}
function extractStewardDelegatedTaskTitle(text = '', sessionType = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) {
return taskMatch[1].trim()
}
return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销'
}
function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') {
const text = String(summary || '').trim()
if (String(sessionType || '').trim() !== 'application') {
return text
}
return text
.replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充')
.replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充')
.replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式')
.replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式')
.replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,、;;\s]*/g, '')
.replace(/[,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '')
.replace(/[,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '')
.replace(/([,、;;。])\1+/g, '$1')
.replace(/[,、;;\s]+。/g, '。')
.replace(/[,、;;\s]+$/g, '')
.trim()
}
function summarizeApplicationPreviewForSteward(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return [
fields.time ? `时间:${fields.time}` : '',
fields.location ? `地点:${fields.location}` : '',
fields.reason ? `事由:${fields.reason}` : '',
fields.applicationType ? `类型:${fields.applicationType}` : ''
].filter(Boolean).join('')
}
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
const rawText = String(context.rawText || '').trim()
const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType)
const taskSummary = sanitizeStewardDelegatedTaskSummary(
extractStewardCarryLine(rawText, '任务摘要'),
sessionType
)
const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview)
|| extractStewardCarryLine(rawText, '已识别信息')
const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充')
const applicationMissingFields = context.applicationPreview
? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview)
: []
const missingInfo = applicationMissingFields.length
? applicationMissingFields.join('、')
: carryMissingInfo
const events = [
{
eventId: `${eventPrefix}-confirm`,
title: '接收确认',
content: '已收到你的确认,小财管家继续推进当前任务。'
eventId: `${eventPrefix}-intent`,
title: '理解当前任务',
content: taskSummary
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}`
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
},
{
eventId: `${eventPrefix}-coordinate`,
title: '协调能力',
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
eventId: `${eventPrefix}-known`,
title: '核对已知信息',
content: identifiedInfo
? `当前已识别到:${identifiedInfo}`
: `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。`
}
]
const applicationMissingFields = context.applicationPreview
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
: []
if (applicationMissingFields.length) {
if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({
eventId: `${eventPrefix}-gap`,
title: '识别缺口',
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
title: '判断待补充信息',
content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
})
} else {
events.push({
eventId: `${eventPrefix}-ready`,
title: '判断下一步动作',
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
})
}
events.push(
{
eventId: `${eventPrefix}-output`,
title: '准备输出',
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
}
)
return events
}
@@ -257,6 +753,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions)
? finalExtras.suggestedActions
: []
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -287,11 +786,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
nextTick(scrollToBottom)
const chars = Array.from(String(eventData.content || ''))
for (let index = 0; index < chars.length; index += 1) {
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
@@ -304,14 +804,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const text = String(finalText || '')
message.text = ''
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.suggestedActions = pendingSuggestedActions
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
const chars = Array.from(text)
for (let index = 0; index < chars.length; index += 1) {
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
message.text = chars.slice(0, index + 1).join('')
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
@@ -670,7 +1172,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
async function buildApplicationPreviewWithModelReview(
rawText,
businessTimeContext = null,
sessionTypeOverride = '',
options = {}
) {
const user = await resolveApplicationPreviewUser()
const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
@@ -697,6 +1204,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
if (options.skipModelReview) {
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'skipped'
}),
meta: ['申请核对预览', '结构化快路径']
}
}
try {
const ontology = await fetchOntologyParse(
{
@@ -828,7 +1345,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, {
if (!stewardDelegated && shouldUseBudgetCompileReport(rawText, {
sessionType: effectiveSessionType,
entrySource: props.entrySource,
budgetContext: props.initialBudgetContext
@@ -944,9 +1461,62 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
rawText,
selectedBusinessTimeContext,
effectiveSessionType
effectiveSessionType,
{
skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview)
}
)
const reviewStatus = String(meta?.[1] || '').trim()
let applicationDateConflict = null
try {
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
} catch (error) {
console.warn('Failed to check overlapping application dates:', error)
}
if (applicationDateConflict) {
const conflictText = buildApplicationDateConflictMessage(applicationDateConflict)
const conflictActions = buildApplicationDateConflictActions(applicationDateConflict)
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
'检测到同日期已有申请,已停止重复创建',
Date.now() - reviewStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
conflictText,
[],
{
meta: ['申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
}
))
} else {
await typeStewardDelegatedMessage(
pendingMessage.id,
conflictText,
{
meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
}
persistSessionState()
nextTick(scrollToBottom)
return null
}
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
@@ -960,16 +1530,43 @@ export function useTravelReimbursementSubmitComposer(ctx) {
)
}
if (stewardDelegated) {
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
applicationPreview,
buildLocalApplicationPreviewMessage(applicationPreview)
)
const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview)
const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision
const slotDecision = shouldFetchSlotDecision
? await fetchStewardApplicationSlotDecision(
applicationPreview,
rawText,
options.stewardContinuation || null
)
: null
const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview)
const pauseForMissingFields = slotDecision
? String(slotDecision.next_action || '').trim() === 'ask_user'
: localPauseForMissingFields
const stewardApplicationText = buildStewardSlotDecisionMessage(
slotDecision,
applicationPreview,
fallbackStewardApplicationText
)
await typeStewardDelegatedMessage(
pendingMessage.id,
buildLocalApplicationPreviewMessage(applicationPreview),
stewardApplicationText,
{
meta,
applicationPreview,
applicationPreview: pauseForMissingFields ? null : applicationPreview,
suggestedActions: slotDecisionActions.length
? slotDecisionActions
: buildStewardApplicationPreviewSuggestedActions(applicationPreview),
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
@@ -1478,6 +2075,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
},
{
sessionType: effectiveSessionType,
rawText,
fileNames: effectiveFileNames,
stewardContinuation: options.stewardContinuation || null
}
)

View File

@@ -51,11 +51,6 @@ const chatViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)),
'utf8'
)
const operationFeedbackInlineTemplate = readFileSync(
fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.vue', import.meta.url)),
'utf8'
)
test('application and reimbursement entries open the same financial assistant modal', () => {
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
@@ -134,8 +129,10 @@ test('application edit prefill opens assistant without auto submit', () => {
)
})
test('financial assistant toolbar renders four isolated assistant sessions', () => {
test('financial assistant toolbar renders isolated assistant sessions without steward entry', () => {
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
assert.match(assistantScript, /\.filter\(\(mode\) => mode\.key !== SESSION_TYPE_STEWARD\)/)
assert.match(assistantScript, /mode\.key === SESSION_TYPE_BUDGET/)
assert.match(assistantScript, /visibleModes\.map/)
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
@@ -199,38 +196,39 @@ test('assistant message meta hides internal routing and permission chips', () =>
assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/)
})
test('assistant operation feedback is inline and persists run context', () => {
test('assistant message action toolbar collects lightweight feedback', () => {
assert.doesNotMatch(appShellRouteView, /<OperationFeedbackDialog/)
assert.doesNotMatch(appShellRouteView, /@operation-completed="handleOperationCompleted"/)
assert.doesNotMatch(appShellComposable, /useOperationFeedback/)
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /class="message-feedback-bubble"/)
assert.match(messageItemTemplate, /:submitted="Boolean\(message\.operationFeedback\?\.submitted\)"/)
assert.match(messageItemTemplate, /:submitted-rating="Number\(message\.operationFeedback\?\.rating \|\| 0\)"/)
assert.doesNotMatch(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /class="message-action-toolbar"/)
assert.match(messageItemTemplate, /ui\.shouldShowAssistantMessageActions\(message\)/)
assert.match(messageItemTemplate, /ui\.copyAssistantMessage\(message\)/)
assert.match(messageItemTemplate, /ui\.speakAssistantMessage\(message\)/)
assert.match(messageItemTemplate, /rating:\s*5,\s*reason:\s*'thumbs_up'/)
assert.match(messageItemTemplate, /rating:\s*1,\s*reason:\s*'thumbs_down'/)
assert.match(messageItemTemplate, /mdi mdi-content-copy/)
assert.match(messageItemTemplate, /mdi mdi-volume-high/)
assert.match(messageItemTemplate, /mdi mdi-thumb-up-outline/)
assert.match(messageItemTemplate, /mdi mdi-thumb-down-outline/)
assert.match(assistantScript, /emits:\s*\['close', 'draft-saved', 'request-updated'\]/)
assert.match(appShellRouteView, /@request-updated="handleRequestUpdated"/)
assert.match(assistantScript, /function buildMessageOperationFeedbackContext/)
assert.match(assistantScript, /source:\s*'assistant_message_action'/)
assert.match(assistantScript, /operation_type:\s*message\?\.stewardPlan \? 'steward_message' : 'assistant_message'/)
assert.match(assistantScript, /function shouldShowAssistantMessageActions/)
assert.match(assistantScript, /function copyAssistantMessage/)
assert.match(assistantScript, /function speakAssistantMessage/)
assert.match(assistantScript, /function isMessageFeedbackSelected/)
assert.match(assistantScript, /function submitOperationFeedbackForMessage/)
assert.match(assistantScript, /createOperationFeedback/)
assert.match(assistantScript, /normalizeOperationFeedbackContext/)
assert.match(assistantScript, /&& !feedback\.dismissed/)
assert.doesNotMatch(assistantScript, /&& !feedback\.submitted/)
assert.match(assistantScript, /submitted:\s*true/)
assert.match(assistantScript, /dismissed:\s*false/)
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
assert.match(operationFeedbackInlineTemplate, /v-for="option in ratingOptions"/)
assert.match(operationFeedbackInlineTemplate, /is-submitted/)
assert.match(operationFeedbackInlineTemplate, /submittedRating/)
assert.match(operationFeedbackInlineTemplate, /感谢您的反馈。谢谢/)
assert.match(operationFeedbackInlineTemplate, /busy \|\| submitted/)
assert.match(operationFeedbackInlineTemplate, /role="radiogroup"/)
assert.match(operationFeedbackInlineTemplate, /handleRatingKeydown/)
assert.match(operationFeedbackInlineTemplate, /operation-feedback-stars/)
assert.match(operationFeedbackInlineTemplate, /score > 3/)
assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/)
assert.match(operationFeedbackInlineTemplate, /稍后/)
const context = normalizeOperationFeedbackContext(
{

View File

@@ -25,6 +25,17 @@ test('document center archived rows are detected from archive flag or request st
expense_type: 'travel_application'
}
}),
false
)
assert.equal(
isArchivedDocumentRow({
rawRequest: {
status: 'approved',
approval_stage: '申请归档',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application'
}
}),
true
)
assert.equal(

View File

@@ -2,13 +2,17 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildDocumentViewedStatePatch,
buildDocumentsViewedStatePatches,
countNewDocuments,
isNewDocument,
markDocumentViewed,
markDocumentsViewed,
mergeNotificationStatesIntoViewedDocumentKeys,
readDocumentScope,
readViewedDocumentKeys,
resolveDocumentNewKey,
resolveDocumentNotificationId,
writeDocumentScope
} from '../src/utils/documentCenterNewState.js'
import { buildDocumentInboxRows } from '../src/composables/useDocumentCenterInbox.js'
@@ -28,6 +32,38 @@ function createMemoryStorage(initial = {}) {
test('document center new state resolves source scoped document keys', () => {
assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1')
assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1')
assert.equal(
resolveDocumentNotificationId({ source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' }),
'document:owned:claim-1'
)
})
test('document center merges backend notification states into viewed keys', () => {
const storage = createMemoryStorage()
const viewedKeys = mergeNotificationStatesIntoViewedDocumentKeys([
{ notification_id: 'document:owned:claim-1', read_at: '2026-06-05T09:00:00+08:00' },
{ notificationId: 'document:approval:claim-2', hiddenAt: '2026-06-05T09:01:00+08:00' },
{ notification_id: 'workbench:todo:claim-3', read_at: '2026-06-05T09:02:00+08:00' }
], readViewedDocumentKeys(storage), storage)
assert.equal(isNewDocument({ source: 'owned', claimId: 'claim-1' }, viewedKeys), false)
assert.equal(isNewDocument({ source: 'approval', claimId: 'claim-2' }, viewedKeys), false)
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1', 'approval:claim-2'])
})
test('document center builds backend viewed-state patches for unread rows', () => {
const rows = [
{ source: 'owned', claimId: 'claim-1', documentKey: 'owned:claim-1' },
{ source: 'approval', claimId: 'claim-2', documentKey: 'approval:claim-2' },
{ source: 'archive', claimId: 'claim-3', documentKey: 'archive:claim-3' }
]
const patches = buildDocumentsViewedStatePatches(rows, new Set(['owned:claim-1']))
assert.deepEqual(patches.map((patch) => patch.notification_id), ['document:approval:claim-2'])
assert.equal(patches[0].read, true)
assert.equal(patches[0].hidden, false)
assert.equal(patches[0].context_json.kind, 'document')
assert.equal(buildDocumentViewedStatePatch(rows[2]), null)
})
test('document center new state counts unseen documents and persists viewed rows', () => {

View File

@@ -208,6 +208,9 @@ test('documents center category tabs render bubble counts for new documents', ()
test('documents center can mark all unread documents as read from toolbar', () => {
assert.match(documentsCenterView, /markDocumentsViewed/)
assert.match(documentsCenterView, /patchNotificationStates/)
assert.match(documentsCenterView, /buildDocumentsViewedStatePatches/)
assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/)
assert.match(
documentsCenterView,
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/
@@ -222,6 +225,10 @@ test('documents center can mark all unread documents as read from toolbar', () =
documentsCenterView,
/function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/
)
assert.match(
documentsCenterView,
/function markAllDocumentsRead\(\) \{[\s\S]*const viewedPatches = buildDocumentsViewedStatePatches\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(viewedPatches\)/
)
assert.match(
documentsCenterView,
/function resolveLiveDocumentRow\(row\) \{[\s\S]*isNewDocument: isNewDocument\(row, viewedDocumentKeys\.value\)[\s\S]*const visibleRows = computed\(\(\) => \{[\s\S]*\.map\(resolveLiveDocumentRow\)/
@@ -232,9 +239,10 @@ test('documents center can mark all unread documents as read from toolbar', () =
test('documents center rows show NEW marker until the row is opened', () => {
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
assert.match(documentsCenterView, /isNewDocument: archived\s*\?\s*false\s*:\s*isNewDocument\(/)
assert.match(documentsCenterView, /buildDocumentViewedStatePatch\(row\)/)
assert.match(
documentsCenterView,
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*syncDocumentViewedPatches\(\[viewedPatch\]\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
)
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/)
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/)

View File

@@ -15,7 +15,10 @@ import {
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
applicationDateRangesOverlap,
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
resolveApplicationTimeLabel,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
@@ -36,6 +39,17 @@ import {
createMessage as createConversationMessage,
hasMeaningfulSessionMessages
} from '../src/views/scripts/travelReimbursementConversationModel.js'
import {
buildStewardSuggestedActions,
filterStewardBlockingMissingFields
} from '../src/views/scripts/stewardPlanModel.js'
import {
buildStewardFieldCompletionContinuation,
buildStewardFieldCompletionRawText
} from '../src/views/scripts/stewardFieldCompletionModel.js'
import {
shouldUseBudgetCompileReport
} from '../src/views/scripts/budgetAssistantReportModel.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
@@ -43,10 +57,22 @@ const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const stewardServiceScript = readFileSync(
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const stewardPlanFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
'utf8'
)
const stewardFieldCompletionScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/stewardFieldCompletionModel.js', import.meta.url)),
'utf8'
)
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
@@ -506,7 +532,7 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(submitComposerScript, /startFlowStep\('intent'/)
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
assert.doesNotMatch(submitComposerScript, /insightPanelCollapsed\.value = true/)
assert.match(submitComposerScript, /function resetStewardDelegatedInsightState\(\) \{[\s\S]*insightPanelCollapsed\.value = true/)
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
assert.ok(
@@ -542,9 +568,16 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
assert.match(messageItemTemplate, /报销草稿待保存/)
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
assert.match(messageItemTemplate, /查看详情/)
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
assert.match(messageItemTemplate, /保存后可查看详情/)
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScript, /canOpenDraftDetail,/)
assert.match(createViewScript, /保存后生成/)
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
assert.ok(
@@ -558,9 +591,15 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/)
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
assert.doesNotMatch(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /class="message-action-toolbar"/)
assert.match(messageItemTemplate, /ui\.shouldShowAssistantMessageActions\(message\)/)
assert.match(messageItemTemplate, /ui\.copyAssistantMessage\(message\)/)
assert.match(messageItemTemplate, /ui\.speakAssistantMessage\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 5, reason: 'thumbs_up' \}\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \{ rating: 1, reason: 'thumbs_down' \}\)/)
assert.match(messageItemTemplate, /mdi mdi-content-copy/)
assert.match(messageItemTemplate, /mdi mdi-volume-high/)
assert.match(submitComposerScript, /employee_grade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(submitComposerScript, /employeeGrade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
@@ -580,7 +619,12 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
assert.match(createViewScript, /function shouldShowAssistantMessageActions/)
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
assert.match(createViewScript, /function isMessageFeedbackSelected/)
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
assert.match(createViewScript, /stewardContinuation:\s*stewardSubmitContinuation/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
@@ -621,9 +665,9 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(flowScript, /return null/)
assert.match(flowScript, /申请单提交成功/)
assert.match(submitComposerScript, /const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'/)
assert.match(submitComposerScript, /if \(isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
assert.match(submitComposerScript, /else if \(rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
assert.match(submitComposerScript, /if \(!stewardDelegated && isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
assert.match(submitComposerScript, /else if \(!stewardDelegated && rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation && !stewardDelegated\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
assert.match(flowScript, /function resolveDurationFromFields/)
assert.match(flowScript, /function resolveStartedTimestamp/)
assert.match(flowScript, /function resolveFinishedTimestamp/)
@@ -631,6 +675,261 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(flowScript, /refreshCompleted/)
})
test('steward application missing transport asks before rendering preview table', () => {
assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/)
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
assert.match(submitComposerScript, /出差费用预算/)
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
assert.match(createViewScript, /payload\.applicationPreview/)
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
assert.match(createViewScript, /skipUserMessage:\s*true/)
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/)
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
assert.match(stewardFieldCompletionScript, /模拟查询交通票据/)
})
test('steward field completion reruns application preview instead of directly rendering table', () => {
const continuation = {
planId: 'steward-plan-transport-gap',
currentTaskId: 'task-application-beijing',
currentTask: {
task_id: 'task-application-beijing',
task_type: 'expense_application',
summary: '明天前往北京出差3天支撑国网仿生产部署',
ontology_fields: {
time_range: '2026-06-05 至 2026-06-07',
location: '北京',
reason: '支撑国网仿生产部署'
},
missing_fields: ['transport_mode']
},
remainingTasks: []
}
const preview = normalizeApplicationPreview({
fields: {
applicationType: '差旅费用申请',
time: '2026-06-05 至 2026-06-07',
location: '北京',
reason: '支撑国网仿生产部署',
days: '3天',
transportMode: ''
}
})
const nextContinuation = buildStewardFieldCompletionContinuation(continuation, 'transportMode', '火车')
assert.equal(nextContinuation.currentTask.ontology_fields.transport_mode, '火车')
assert.deepEqual(nextContinuation.currentTask.missing_fields, [])
const carryText = buildStewardFieldCompletionRawText({
preview,
fieldKey: 'transportMode',
fieldLabel: '出行方式',
value: '火车',
continuation: nextContinuation
})
assert.match(carryText, /用户已补充:出行方式:火车/)
assert.match(carryText, /地点:北京/)
assert.match(carryText, /天数3天/)
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/)
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
assert.equal(rebuiltPreview.fields.location, '北京')
assert.equal(rebuiltPreview.fields.transportMode, '火车')
assert.equal(rebuiltPreview.fields.days, '3天')
})
test('budget compile report does not steal steward delegated application rerun', () => {
const staleBudgetContext = {
budgetNo: 'BUD-2026-TECH',
mode: 'edit',
categoryRows: []
}
const stewardApplicationText = [
'小财管家继续执行申请单字段补齐。',
'用户已补充:出行方式:火车。',
'地点:北京',
'天数3天',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。'
].join('\n')
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
sessionType: 'application',
entrySource: 'workbench',
budgetContext: staleBudgetContext
}), false)
assert.equal(shouldUseBudgetCompileReport('帮我生成 2026 年 Q3 预算编制建议', {
sessionType: 'budget',
entrySource: 'budget',
budgetContext: staleBudgetContext
}), true)
assert.match(submitComposerScript, /if \(!stewardDelegated && shouldUseBudgetCompileReport/)
})
test('text confirmation submits pending application preview before replanning steward task', () => {
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
assert.match(createViewScript, /function buildStewardRuntimeState/)
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
assert.match(createViewScript, /async function handleStewardRuntimeDecision/)
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
assert.match(createViewScript, /message\.applicationSubmitConfirmed = true/)
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
})
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
})
test('steward initial workbench entry shows recognition state before messages arrive', () => {
assert.match(createViewScript, /const hasStewardInitialAutoSubmitPayload = computed/)
assert.match(createViewScript, /const showStewardInitialRecognition = computed/)
assert.match(createViewScript, /!messages\.value\.length/)
assert.match(createViewScript, /workbenchVisible\.value \|\| submitting\.value/)
assert.match(createViewScript, /showStewardInitialRecognition/)
assert.match(createViewTemplate, /v-if="showStewardInitialRecognition"/)
assert.match(createViewTemplate, /class="steward-initial-recognition"/)
assert.match(createViewTemplate, /小财管家正在识别意图/)
})
test('steward application carry text does not leak transport examples into extraction', () => {
const actions = buildStewardSuggestedActions({
plan_id: 'steward-plan-transport-gap',
plan_status: 'ready',
tasks: [
{
task_id: 'task-application-beijing',
task_type: 'expense_application',
title: '北京出差申请',
summary: '明天前往北京出差3天支撑国网仿生产部署',
assigned_agent: 'application_assistant',
ontology_fields: {
expense_type: 'travel',
time_range: '2026-06-05 至 2026-06-07',
location: '北京',
reason: '支撑国网仿生产部署'
},
missing_fields: ['transport_mode', 'amount', 'attachments', 'employee_no']
}
],
confirmation_groups: [
{
action_type: 'confirm_create_application',
target_task_id: 'task-application-beijing'
}
]
})
const carryText = actions[0]?.payload?.carry_text || ''
const currentTask = actions[0]?.payload?.steward_current_task || null
assert.match(carryText, /费用类型:差旅/)
assert.doesNotMatch(carryText, /费用类型travel/)
assert.match(carryText, /还需要补充:出行方式/)
assert.match(carryText, /请先追问上述缺失信息/)
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
assert.equal(currentTask?.task_type, 'expense_application')
assert.deepEqual(currentTask?.missing_fields, ['transport_mode'])
assert.deepEqual(
filterStewardBlockingMissingFields(
['transport_type', 'amount', 'attachments', 'employee_no', 'department_name'],
'expense_application'
),
['transport_mode']
)
assert.deepEqual(
filterStewardBlockingMissingFields(['amount', 'attachments'], 'reimbursement'),
['amount', 'attachments']
)
const preview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
assert.equal(preview.fields.transportMode, '')
assert.equal(preview.missingFields.includes('出行方式'), true)
assert.match(stewardServiceScript, /fetchStewardSlotDecision/)
assert.match(stewardServiceScript, /\/steward\/slot-decisions/)
assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
assert.match(createViewScript, /currentTask:\s*actionPayload\.steward_current_task/)
})
test('steward application slot fallback ignores non-blocking application fields', () => {
assert.match(submitComposerScript, /APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS/)
assert.match(submitComposerScript, /'attachments'/)
assert.match(submitComposerScript, /'employee_no'/)
assert.match(submitComposerScript, /'amount'/)
assert.match(submitComposerScript, /function formatStewardDecisionUserText/)
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/)
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/)
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
assert.equal(normalizeTransportModeOption('自驾', ''), '')
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
assert.match(submitComposerScript, /isBlockingApplicationOntologyField\(key\)/)
assert.match(submitComposerScript, /canonicalField && !isBlockingApplicationOntologyField\(canonicalField\)/)
assert.doesNotMatch(submitComposerScript, /附件\/凭证和员工编号为合规必需字段/)
})
test('flow panel durations use backend timing instead of local preview delay', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
@@ -750,6 +1049,42 @@ test('assistant markdown tables render with component-scoped table styling', ()
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/)
})
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
const rendered = renderMarkdown([
'识别到您希望报销一笔“业务招待费”费用:',
'',
'基础信息识别结果:',
'时间2026-06-04',
'事由:小财管家继续执行剩余任务,请填写报销单:客户接待费用报销。',
'',
'报销测算参考:',
'先以用户填写金额或票据识别金额为基础,再结合费用类型、发生地点、业务事由和规则中心限额进行复核。'
].join('\n'))
assert.match(rendered, /<h3>基础信息识别结果<\/h3>/)
assert.match(rendered, /<li><strong>时间<\/strong>2026-06-04<\/li>/)
assert.match(rendered, /<li><strong>事由<\/strong>:小财管家继续执行剩余任务/)
assert.match(rendered, /<h3>报销测算参考<\/h3>/)
assert.doesNotMatch(rendered, /基础信息识别结果:<\/h3>/)
})
test('application date overlap blocks steward preview before duplicate application table', () => {
const existingRange = resolveApplicationDateRange('2026-06-05 至 2026-06-07')
const currentRange = resolveApplicationDateRange('2026-06-06 至 2026-06-08')
const disjointRange = resolveApplicationDateRange('2026-06-08 至 2026-06-10')
assert.equal(applicationDateRangesOverlap(currentRange, existingRange), true)
assert.equal(applicationDateRangesOverlap(disjointRange, existingRange), false)
assert.match(submitComposerScript, /function findOverlappingApplicationClaim\(applicationPreview, claimsPayload\)/)
assert.match(submitComposerScript, /function normalizeApplicationExpenseType\(value\)/)
assert.match(submitComposerScript, /currentExpenseType !== existingExpenseType/)
assert.match(submitComposerScript, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
assert.match(createViewScript, /actionType === 'open_application_detail'/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天服务项目部署火车预计费用1800元', {
name: '李文静',

View File

@@ -17,6 +17,15 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => {
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application'
}),
false
)
assert.equal(
isArchivedExpenseClaim({
status: 'approved',
approval_stage: '申请归档',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application'
}),
true
)
assert.equal(

View File

@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const root = process.cwd()
function readProjectFile(path) {
return readFileSync(join(root, path), 'utf8')
}
function testSharedDropdownStyleOwnsDocumentCenterPattern() {
const sharedStyles = readProjectFile('web/src/assets/styles/components/document-list-shared.css')
const documentStyles = readProjectFile('web/src/assets/styles/views/documents-center-view.css')
const logsStyles = readProjectFile('web/src/assets/styles/views/logs-view.css')
assert.match(sharedStyles, /\.document-filter-menu\b/)
assert.match(sharedStyles, /\.date-range-popover\b/)
assert.match(sharedStyles, /\.status-filter-trigger\b/)
assert.doesNotMatch(documentStyles, /\.document-filter-menu\b/)
assert.doesNotMatch(logsStyles, /\.document-filter-menu\b[\s\S]*box-shadow/)
}
function testListViewsUseSharedDropdownFilter() {
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
const budgetView = readProjectFile('web/src/views/BudgetCenterView.vue')
const budgetScript = readProjectFile('web/src/views/scripts/BudgetCenterView.js')
const employeeView = readProjectFile('web/src/views/EmployeeManagementView.vue')
const employeeScript = readProjectFile('web/src/views/scripts/EmployeeManagementView.js')
const logsView = readProjectFile('web/src/views/LogsView.vue')
const auditPicker = readProjectFile('web/src/components/audit/AuditPickerFilter.vue')
const dropdown = readProjectFile('web/src/components/shared/DocumentDropdownFilter.vue')
assert.match(dropdown, /class="picker-filter document-filter"/)
assert.match(dropdown, /class="picker-trigger filter-btn"/)
assert.match(dropdown, /class="picker-popover document-filter-menu"/)
assert.match(receiptView, /DocumentDropdownFilter/)
assert.match(budgetView, /DocumentDropdownFilter/)
assert.match(budgetScript, /DocumentDropdownFilter/)
assert.doesNotMatch(budgetScript, /EnterpriseSelect/)
assert.match(employeeView, /DocumentDropdownFilter/)
assert.match(employeeScript, /DocumentDropdownFilter/)
assert.match(logsView, /document-list-shared\.css/)
assert.match(auditPicker, /document-filter-menu/)
assert.doesNotMatch(auditPicker, /<header>/)
}
function run() {
testSharedDropdownStyleOwnsDocumentCenterPattern()
testListViewsUseSharedDropdownFilter()
console.log('list dropdown filter style tests passed')
}
run()

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
NOTIFICATION_STATE_BATCH_SIZE,
chunkNotificationStatePatches
} from '../src/services/notificationStates.js'
test('notification state patches are split before posting to backend', () => {
const patches = Array.from({ length: 205 }, (_, index) => ({
notification_id: `document:owned:DOC-${index + 1}`,
read: true
}))
const chunks = chunkNotificationStatePatches(patches)
assert.equal(NOTIFICATION_STATE_BATCH_SIZE, 100)
assert.deepEqual(chunks.map((chunk) => chunk.length), [100, 100, 5])
assert.equal(chunks[0][0].notification_id, 'document:owned:DOC-1')
assert.equal(chunks[2][4].notification_id, 'document:owned:DOC-205')
})

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const responsiveStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)),
'utf8'
)
test('personal workbench compacts hero input and capability cards on laptop screens', () => {
assert.match(
responsiveStyles,
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
)
assert.match(responsiveStyles, /--hero-padding-top:\s*14px;/)
assert.match(responsiveStyles, /--hero-padding-bottom:\s*14px;/)
assert.match(responsiveStyles, /--hero-title-size:\s*24px;/)
assert.match(responsiveStyles, /--composer-min-height:\s*92px;/)
assert.match(responsiveStyles, /--composer-textarea-height:\s*38px;/)
assert.match(responsiveStyles, /--capability-row-height:\s*82px;/)
assert.match(responsiveStyles, /\.assistant-copy h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
assert.match(responsiveStyles, /\.assistant-composer\s*\{[\s\S]*padding:\s*var\(--composer-padding-block\) 14px 8px;/)
assert.match(responsiveStyles, /\.quick-prompts button\s*\{[\s\S]*min-height:\s*24px;/)
assert.match(responsiveStyles, /\.capability-card\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 14px;[\s\S]*padding:\s*12px 12px 12px 16px;/)
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*grid-template-rows:\s*none;/)
})

View File

@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import {
RECEIPT_FILTER_ALL,
applyReceiptListFilters,
buildReceiptFilterControls,
buildReceiptFilterTokens
} from '../src/views/scripts/receiptFolderListFilters.js'
const rows = [
{
id: 'r-1',
document_type: 'train_ticket',
document_type_label: '火车票',
scene_code: 'travel',
scene_label: '差旅',
document_date: '2026-05-02',
avg_score: 0.96
},
{
id: 'r-2',
document_type: 'vat_invoice',
document_type_label: '增值税发票',
scene_code: 'office',
scene_label: '办公',
uploaded_at: '2026-04-18T10:00:00Z',
avg_score: 0.82
},
{
id: 'r-3',
document_type: 'vat_invoice',
document_type_label: '增值税发票',
scene_code: 'travel',
scene_label: '差旅',
document_date: '',
uploaded_at: '2026-05-20T08:00:00Z',
avg_score: 0
}
]
function testBuildsDynamicOptions() {
const controls = buildReceiptFilterControls(rows, {})
const typeControl = controls.find((item) => item.key === 'documentType')
const monthControl = controls.find((item) => item.key === 'month')
assert.equal(typeControl.options[0].value, RECEIPT_FILTER_ALL)
assert.deepEqual(typeControl.options.map((item) => item.value).sort(), ['all', 'train_ticket', 'vat_invoice'])
assert.deepEqual(monthControl.options.map((item) => item.value), ['all', '2026-05', '2026-04'])
}
function testAppliesCombinedFilters() {
const result = applyReceiptListFilters(rows, {
documentType: 'vat_invoice',
scene: 'travel',
month: '2026-05',
quality: 'missing'
})
assert.deepEqual(result.map((item) => item.id), ['r-3'])
}
function testBuildsReadableTokens() {
const filters = {
documentType: 'train_ticket',
scene: 'travel',
month: RECEIPT_FILTER_ALL,
quality: 'high'
}
const tokens = buildReceiptFilterTokens(buildReceiptFilterControls(rows, filters), filters)
assert.deepEqual(tokens, ['票据类型:火车票', '费用场景:差旅', '置信度:高置信度'])
}
function run() {
testBuildsDynamicOptions()
testAppliesCombinedFilters()
testBuildsReadableTokens()
console.log('receipt folder list filter tests passed')
}
run()

View File

@@ -48,6 +48,12 @@ function testReceiptFolderViewSurface() {
assert.match(view, /deleteCurrentReceipt/)
assert.match(view, /ElCheckboxGroup/)
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
assert.match(view, /DocumentDropdownFilter/)
assert.match(view, /receiptFilterControls/)
assert.match(view, /clear-filter-btn/)
assert.match(view, /receiptFilters\[control\.key\]/)
assert.match(view, /clearReceiptFilters/)
assert.doesNotMatch(view, /class="filter-btn" type="button" @click="reloadReceipts"/)
assert.match(view, /buildReceiptFile\(item\)/)
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
assert.match(view, /emit\('open-assistant'/)
@@ -92,9 +98,13 @@ function testSharedDocumentListStyleReuse() {
assert.match(sharedStyles, /\.table-wrap\b/)
assert.match(sharedStyles, /\.doc-kind-tag\b/)
assert.match(sharedStyles, /\.list-foot\b/)
assert.match(sharedStyles, /\.clear-filter-btn\b/)
assert.match(sharedStyles, /\.document-filter-menu\b/)
assert.doesNotMatch(receiptStyles, /\.table-wrap\b/)
assert.doesNotMatch(receiptStyles, /\.doc-kind-tag\b/)
assert.doesNotMatch(receiptStyles, /\.list-foot\b/)
assert.doesNotMatch(receiptStyles, /\.receipt-select-filter\b/)
assert.doesNotMatch(receiptStyles, /\.receipt-clear-filters\b/)
}
function testReceiptFolderDetailLayoutAdjustments() {

View File

@@ -11,6 +11,8 @@ const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001'
const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863'
const RETURNED = '\u9000\u56de'
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
const LINKED_APPLICATION = '\u5173\u8054\u5355\u636e'
@@ -156,7 +158,7 @@ test('application claims are mapped as application documents', () => {
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
@@ -250,7 +252,7 @@ test('application claims wait for department P8 budget monitor after leader appr
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED]
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
@@ -293,7 +295,7 @@ test('application budget wait label uses claim-level budget approver snapshot',
assert.equal(request.budgetApproverName, 'P8 Executive')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED]
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
})
@@ -386,14 +388,16 @@ test('approved application claims complete after budget approval', () => {
})
assert.equal(request.documentTypeCode, 'application')
assert.equal(request.workflowNode, '审批完成')
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
assert.deepEqual(
request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '预算管理者审批', '审批完成']
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过')
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联')
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李经理通过')
assert.equal(request.progressSteps.find((step) => step.label === BUDGET_MANAGER_APPROVAL)?.time, '赵预算通过')
})
test('application claims hide budget step when leader approval also covers budget approval', () => {
@@ -430,9 +434,10 @@ test('application claims hide budget step when leader approval also covers budge
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李预算经理通过')
})
@@ -481,13 +486,92 @@ test('approved application claims hide budget step when dynamic route skipped bu
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
})
test('approved application claims show linked reimbursement status before archive', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-linked-draft',
claim_no: 'AP-202606050001-LINKED',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 500,
invoice_count: 0,
occurred_at: '2026-06-05T00:00:00.000Z',
submitted_at: '2026-06-05T02:00:00.000Z',
created_at: '2026-06-05T01:30:00.000Z',
updated_at: '2026-06-05T03:00:00.000Z',
status: 'approved',
approval_stage: APPLICATION_LINK_STATUS,
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'Leader Li',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: APPLICATION_LINK_STATUS,
generated_draft_claim_no: 'RE-202606050001-LINKED',
created_at: '2026-06-05T03:00:00.000Z'
}
],
items: []
})
const linkStep = request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
assert.equal(linkStep?.current, true)
assert.equal(linkStep?.time, '关联中 RE-202606050001-LINKED')
assert.equal(request.secondaryStatusValue, '关联中 RE-202606050001-LINKED')
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
})
test('application claims are archived only after linked reimbursement is paid', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-archived',
claim_no: 'AP-202606050001-ARCHIVED',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 500,
invoice_count: 0,
occurred_at: '2026-06-05T00:00:00.000Z',
submitted_at: '2026-06-05T02:00:00.000Z',
created_at: '2026-06-05T01:30:00.000Z',
updated_at: '2026-06-07T03:00:00.000Z',
status: 'approved',
approval_stage: APPLICATION_ARCHIVE,
risk_flags_json: [
{
source: 'application_archive_sync',
event_type: 'expense_application_archived_by_reimbursement',
reimbursement_claim_no: 'RE-202606050001-ARCHIVED',
created_at: '2026-06-07T03:00:00.000Z'
}
],
items: []
})
assert.equal(request.workflowNode, APPLICATION_ARCHIVE)
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.secondaryStatusValue, '已归档')
})
test('progress steps show approval operator time and current stay duration', () => {
const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()

View File

@@ -63,6 +63,7 @@ test('sidebar no longer renders document center unread indicators', () => {
test('topbar bell owns document center unread notifications', () => {
assert.match(topbar, /useDocumentCenterInbox/)
assert.match(topbar, /useTopBarNotificationStates/)
assert.match(topbar, /resolveDocumentNotificationId/)
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
assert.match(topbar, /const documentNotificationItems = computed/)
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
@@ -109,6 +110,8 @@ test('topbar notification state is persisted through backend API with local fall
test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /fetchNotificationStates/)
assert.match(documentInbox, /mergeNotificationStatesIntoViewedDocumentKeys/)
assert.match(documentInbox, /readViewedDocumentKeys/)
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentInbox, /const notificationRows = computed/)
@@ -120,5 +123,7 @@ test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
assert.match(documentInbox, /window\.addEventListener\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys\)/)
assert.match(documentNewState, /export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentNewState, /resolveDocumentNotificationId/)
assert.match(documentNewState, /buildDocumentsViewedStatePatches/)
assert.match(documentNewState, /window\.dispatchEvent\(new CustomEvent\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT\)\)/)
})

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const topbarStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)),
'utf8'
)
test('topbar uses a compact laptop layout without overriding mobile layout', () => {
assert.match(
topbarStyles,
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
)
assert.match(topbarStyles, /\.topbar\s*\{[\s\S]*padding:\s*12px 20px 14px;/)
assert.match(topbarStyles, /\.topbar h1\s*\{[\s\S]*font-size:\s*22px;/)
assert.match(topbarStyles, /\.topbar p\s*\{[\s\S]*-webkit-line-clamp:\s*1;/)
assert.match(topbarStyles, /\.range-shell\s*\{[\s\S]*height:\s*36px;/)
assert.match(topbarStyles, /\.dashboard-switch-select :deep\(\.el-select__wrapper\)\s*\{[\s\S]*min-height:\s*38px;/)
assert.match(topbarStyles, /\.topbar-icon-btn\s*\{[\s\S]*width:\s*30px;[\s\S]*height:\s*30px;/)
assert.match(topbarStyles, /@media \(max-width: 960px\)[\s\S]*\.topbar\s*\{[\s\S]*flex-direction:\s*column;/)
})

View File

@@ -118,6 +118,21 @@ test('document review drawer fills sidebar height and preview dialog is centered
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
})
test('assistant conversation keeps composer visible when generated cards grow tall', () => {
assert.match(createViewBaseStyles, /\.assistant-layout\s*\{[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*overflow:\s*hidden;/)
assert.match(
createViewBaseStyles,
/\.dialog-panel\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*height:\s*100%;[\s\S]*max-height:\s*100%;[\s\S]*min-height:\s*0;[\s\S]*overflow:\s*hidden;/
)
assert.match(createViewBaseStyles, /\.dialog-toolbar\s*\{[\s\S]*flex:\s*0 0 auto;/)
assert.match(
createViewBaseStyles,
/\.message-list\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*min-height:\s*0;[\s\S]*max-height:\s*100%;[\s\S]*overflow-y:\s*auto;[\s\S]*overscroll-behavior:\s*contain;/
)
assert.match(createViewBaseStyles, /\.composer\s*\{[\s\S]*position:\s*sticky;[\s\S]*bottom:\s*0;[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-shrink:\s*0;/)
assert.match(createViewPart4Styles, /@media \(max-width:\s*1440px\)[\s\S]*\.dialog-panel\s*\{[\s\S]*flex:\s*1 1 0;[\s\S]*height:\s*auto;[\s\S]*max-height:\s*100%;/)
})
test('document review OCR result card header keeps copy and navigation separated', () => {
assert.match(insightPanelTemplate, /class="review-side-head-copy"[\s\S]*票据识别结果卡片[\s\S]*逐张查看 OCR 结果/)
assert.match(insightPanelStyles, /\.review-document-switch-head\s*\{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/)
@@ -619,7 +634,10 @@ test('guided save draft emits refresh and exposes reimbursement draft detail car
/emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/
)
assert.match(createViewScript, /function shouldShowDraftSavedCard\(message\)/)
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScript, /function resolveReimbursementDraftClaimNo\(draftPayload\)/)
assert.doesNotMatch(createViewScript, /function buildReimbursementDraftSummaryItems\(draftPayload\)/)
assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/)
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
assert.match(messageItemTemplate, /reimbursement-draft-pending-detail/)
})

View File

@@ -87,3 +87,52 @@ test('workbench summary builds real user notifications and progress from request
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办'))
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度'))
})
test('workbench progress keeps application document type for AP claims', () => {
const summary = buildWorkbenchSummary(
[
{
id: 'AP-202606050001-ABCDEFGH',
claimId: 'application-1',
claimNo: 'AP-202606050001-ABCDEFGH',
person: currentUser.name,
title: '差旅费用',
approvalKey: 'in_progress',
approvalStatus: '直属领导审批',
amount: 1880,
createdAt: '2026-06-05T09:00:00+08:00',
updatedAt: '2026-06-05T09:10:00+08:00',
progressSteps: [
buildStep('创建申请', 0, 1),
buildStep('直属领导审批', 1, 1),
buildStep('归档', 2, 1)
]
},
{
id: 'REQ-APPLICATION-001',
claimId: 'application-2',
claimNo: 'REQ-APPLICATION-001',
documentTypeCode: 'application',
documentTypeLabel: '报销单',
person: currentUser.name,
title: '办公用品采购',
approvalKey: 'in_progress',
approvalStatus: '直属领导审批',
amount: 2600,
createdAt: '2026-06-05T09:05:00+08:00',
updatedAt: '2026-06-05T09:15:00+08:00',
progressSteps: [
buildStep('创建申请', 0, 1),
buildStep('直属领导审批', 1, 1),
buildStep('归档', 2, 1)
]
}
],
currentUser
)
assert.deepEqual(
summary.progressItems.map((item) => item.documentTypeLabel),
['申请单', '申请单']
)
})