feat: 优化差旅报销预审流程与个人工作台 UI 体系

- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 14:01:51 +08:00
parent 92444e7eae
commit ca691f3ee0
107 changed files with 5663 additions and 1542 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

View File

@@ -207,6 +207,10 @@
background: #fff;
}
.mobile-hamburger-btn {
display: none;
}
@keyframes loginEntrySidebarIn {
from {
opacity: 0;
@@ -270,9 +274,28 @@
.app > .main {
flex: 1 1 100%;
width: 100vw;
max-width: 100vw;
min-width: 0;
overflow: hidden;
}
.workarea {
min-width: 0;
max-width: 100%;
padding: 16px;
}
.workarea.documents-workarea,
.workarea.receipt-folder-workarea,
.workarea.budget-workarea,
.workarea.policies-workarea,
.workarea.audit-workarea,
.workarea.employees-workarea {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.workarea { padding: 16px; }
.workarea.workbench-workarea { overflow: auto; padding: 16px; }
.mobile-overlay {

View File

@@ -477,7 +477,18 @@ td small {
@media (max-width: 760px) {
.status-tabs {
gap: 18px;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.status-tabs button span {
white-space: nowrap;
}
.filter-set,
@@ -492,4 +503,111 @@ td small {
display: grid;
justify-items: stretch;
}
.pager {
width: 100%;
max-width: 100%;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: thin;
}
.pager button {
flex: 0 0 auto;
}
.documents-list .table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.documents-list .table-wrap table,
.documents-list .table-wrap thead,
.documents-list .table-wrap tbody,
.documents-list .table-wrap tr,
.documents-list .table-wrap th,
.documents-list .table-wrap td {
display: block;
}
.documents-list .table-wrap table {
min-width: 0;
width: 100%;
border-collapse: separate;
}
.documents-list .table-wrap thead,
.documents-list .table-wrap colgroup {
display: none;
}
.documents-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.documents-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.documents-list .table-wrap tr:hover {
background: #f8fbff;
}
.documents-list .table-wrap td {
position: relative;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: start;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.documents-list .table-wrap td:last-child {
border-bottom: 0;
}
.documents-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.7;
}
.documents-list .table-wrap td > * {
min-width: 0;
}
.documents-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.documents-list .table-wrap td:first-child::before {
display: none;
}
.documents-list .table-wrap td[data-label="事项"] {
grid-template-columns: 1fr;
}
.documents-list .table-wrap td[data-label="事项"]::before {
margin-bottom: 2px;
}
}

View File

@@ -654,8 +654,7 @@
.enterprise-list-page .create-request-btn,
.enterprise-list-page .create-btn,
.enterprise-list-page .export-btn,
.enterprise-list-page .template-btn,
.enterprise-list-page .page-size-select {
.enterprise-list-page .template-btn {
width: 100%;
}
@@ -666,8 +665,35 @@
justify-items: stretch;
}
.enterprise-list-page .pager,
.enterprise-list-page .list-foot {
gap: 10px;
margin-top: 10px;
}
.enterprise-list-page .page-summary {
justify-self: center;
max-width: 100%;
color: #64748b;
font-size: 12px;
line-height: 1.55;
text-align: center;
}
.enterprise-list-page .pager {
width: 100%;
max-width: 100%;
justify-self: center;
flex-wrap: wrap;
overflow-x: visible;
}
.enterprise-list-page .pager button {
flex: 0 0 32px;
}
.enterprise-list-page .page-size-select {
justify-self: stretch;
width: 160px;
max-width: 100%;
justify-self: center;
}
}

View File

@@ -0,0 +1,162 @@
.workbench {
--workbench-capability-bg-image: url("../../personal-workbench-card-glass-capability.webp");
--workbench-panel-bg-image: url("../../personal-workbench-card-glass-panel.webp");
--workbench-capability-tile-size: 384px 384px;
--workbench-panel-tile-size: 512px 512px;
--workbench-glass-base:
linear-gradient(135deg, rgba(255, 255, 255, 0.76) 0%, rgba(255, 255, 255, 0.62) 54%, rgba(255, 255, 255, 0.7) 100%);
--workbench-glass-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.022) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.052) 100%);
--workbench-glass-highlight:
linear-gradient(120deg, rgba(255, 255, 255, 0.5) 0%, transparent 16%, transparent 82%, rgba(255, 255, 255, 0.22) 100%);
--workbench-glass-noise-opacity: 0.012;
--workbench-glass-blur: blur(18px) saturate(1.28);
}
.capability-card {
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);
}
.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);
}
.capability-card::after {
border: 1px solid rgba(255, 255, 255, 0.38);
border-left: 0;
border-radius: inherit;
background: var(--workbench-glass-highlight);
opacity: 0.58;
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.06);
transition: opacity 180ms var(--ease);
}
.capability-icon,
.capability-copy,
.capability-arrow {
position: relative;
z-index: 1;
}
.capability-icon {
border: 1px solid color-mix(in srgb, var(--capability-color) 18%, rgba(255, 255, 255, 0.68));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.24)),
color-mix(in srgb, var(--capability-soft) 72%, transparent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
}
.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);
}
.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);
}
.workbench-card > * {
position: relative;
z-index: 1;
}
.todo-row,
.progress-row {
position: relative;
border-top: 0;
background: transparent;
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
}
.todo-row:first-child,
.progress-row:first-child {
box-shadow: none;
}
.todo-row:hover,
.progress-row:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.035);
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
}
.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;
}
.capability-card:hover {
transform: translateY(-1px);
}

View File

@@ -78,10 +78,17 @@
gap: 10px;
min-height: 0;
padding: 7px 9px;
border: 1px solid var(--workbench-line-soft);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
border-left: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3);
border-radius: 4px;
background: #ffffff;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.58)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.026);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.68),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.055);
backdrop-filter: blur(10px) saturate(1.16);
-webkit-backdrop-filter: blur(10px) saturate(1.16);
transition:
border-color 180ms var(--ease),
background-color 180ms var(--ease);
@@ -90,7 +97,9 @@
.insight-metric-row:hover,
.insight-profile-card:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
background: #fbfdff;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.64)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
}
.insight-metric-label,

View File

@@ -15,7 +15,7 @@
}
.assistant-hero {
--assistant-art-width: min(560px, 42vw);
--assistant-bg-position: 56% center;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
}
@@ -58,9 +58,7 @@
}
.assistant-hero {
--assistant-art-width: min(620px, 44vw);
--assistant-art-x: 48px;
--assistant-art-y: -10px;
--assistant-bg-position: 58% center;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
}
@@ -112,12 +110,11 @@
}
.assistant-hero {
--assistant-art-width: min(540px, 50vw);
--assistant-art-x: 36px;
--assistant-art-y: -8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.6) 56%, rgba(255, 255, 255, 0.22) 100%),
linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, color-mix(in srgb, var(--workbench-primary-soft) 40%, rgba(255, 255, 255, 0.5)) 58%, color-mix(in srgb, var(--workbench-secondary) 15%, rgba(255, 255, 255, 0.1)) 100%);
--assistant-bg-position: 62% center;
--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%);
--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);
}
@@ -149,17 +146,23 @@
grid-template-rows: none;
gap: 14px;
overflow: visible;
--workbench-glass-base:
linear-gradient(135deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.68) 56%, rgba(255, 255, 255, 0.76));
--workbench-glass-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.065), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.018));
--workbench-glass-noise-opacity: 0.008;
--workbench-glass-blur: blur(14px) saturate(1.2);
}
.assistant-hero {
min-height: auto;
--assistant-art-width: min(380px, 78vw);
--assistant-art-x: 12px;
--assistant-art-y: -6px;
--assistant-bg-position: 68% center;
--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%);
--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%);
padding: 24px 18px 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.7) 100%),
color-mix(in srgb, var(--workbench-primary-soft) 22%, rgba(255, 255, 255, 0.5));
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
@@ -262,7 +265,7 @@
}
.assistant-hero {
--assistant-art-width: min(280px, 70vw);
--assistant-bg-position: 72% center;
padding: 20px 14px 20px;
}
}

View File

@@ -53,19 +53,25 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero {
--assistant-art-width: min(680px, 46vw);
--assistant-art-x: 56px;
--assistant-art-y: -12px;
--assistant-bg-position: center right;
--assistant-bg-size: cover;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.86) 42%, rgba(255, 255, 255, 0.44) 68%, rgba(255, 255, 255, 0.18) 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.07) 52%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16) 100%);
position: relative;
z-index: 2;
min-height: 0;
overflow: visible;
overflow: hidden;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid color-mix(in srgb, var(--workbench-primary) 14%, var(--workbench-line));
border-radius: 4px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.6) 44%, rgba(255, 255, 255, 0.2) 66%, rgba(255, 255, 255, 0.05) 100%),
linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, color-mix(in srgb, var(--workbench-primary-soft) 40%, rgba(255, 255, 255, 0.5)) 62%, color-mix(in srgb, var(--workbench-secondary) 15%, rgba(255, 255, 255, 0.1)) 100%);
var(--assistant-readability-mask),
var(--assistant-theme-tint),
var(--assistant-bg-image) var(--assistant-bg-position) / var(--assistant-bg-size) no-repeat;
background-color: color-mix(in srgb, var(--workbench-primary-soft) 42%, #ffffff);
background-blend-mode: normal, color, luminosity;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.6);
@@ -73,15 +79,7 @@
}
.assistant-hero::after {
content: "";
position: absolute;
right: var(--assistant-art-x);
bottom: var(--assistant-art-y);
width: var(--assistant-art-width);
height: calc(100% + 28px);
background: var(--assistant-bg-image) right bottom / auto 112% no-repeat;
pointer-events: none;
z-index: 2;
content: none;
}
.assistant-hero::before {
@@ -90,7 +88,8 @@
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 58%);
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;
}
@@ -317,7 +316,6 @@
.capability-card {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 10px;
align-items: center;
@@ -331,6 +329,11 @@
background: var(--workbench-surface);
text-align: left;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
transition:
border-color 180ms var(--ease),
box-shadow 180ms var(--ease),
color 180ms var(--ease),
transform 180ms var(--ease);
}
.capability-card::after {

View File

@@ -1,4 +1,7 @@
.topbar {
box-sizing: border-box;
width: 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
@@ -14,6 +17,7 @@
.title-group {
min-width: 0;
max-width: 100%;
}
.eyebrow {
@@ -46,6 +50,8 @@
}
.top-actions {
min-width: 0;
max-width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -282,6 +288,8 @@
}
.kpi-chips {
min-width: 0;
max-width: 100%;
display: flex;
gap: 10px;
}
@@ -531,6 +539,9 @@
@media (max-width: 960px) {
.topbar {
width: 100%;
min-width: 0;
max-width: 100%;
flex-direction: column;
align-items: stretch;
}
@@ -596,18 +607,26 @@
.kpi-chips {
width: 100%;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
min-width: 0;
max-width: 100%;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: 8px;
overflow: visible;
padding-bottom: 0;
}
.kpi-chip {
min-width: 118px;
min-width: 0;
padding: 8px 12px;
}
.chip-value,
.chip-label,
.chip-delta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -161,6 +161,30 @@
font-size: var(--wb-fs-insight-h4, 14px);
}
.review-document-switch-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.review-side-head-copy {
min-width: 0;
display: grid;
gap: 4px;
text-align: left;
}
.review-side-head-copy strong {
line-height: 1.35;
}
.review-side-head-copy p {
max-width: 100%;
margin: 0;
overflow-wrap: anywhere;
word-break: normal;
}
.note-block {
display: grid;
gap: 6px;
@@ -289,6 +313,26 @@
gap: 8px;
}
.review-document-nav {
flex: 0 0 auto;
align-items: center;
flex-wrap: nowrap;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #ffffff;
white-space: nowrap;
}
.review-document-nav span {
min-width: 38px;
color: #334155;
font-size: 11px;
font-weight: 850;
text-align: center;
font-variant-numeric: tabular-nums;
}
.review-insight-switch-icon-btn,
.flow-icon-btn,
.review-document-nav-btn,

View File

@@ -16,6 +16,89 @@
background: #ffffff;
}
.application-draft-preview.reimbursement-draft-preview {
max-width: 520px;
padding: 10px 12px;
border-color: #d8e4f0;
background: #ffffff;
}
.reimbursement-draft-card {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.reimbursement-draft-icon {
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2);
border-radius: 4px;
background: #f7fbff;
color: var(--theme-primary-active, #255b7d);
font-size: 16px;
}
.reimbursement-draft-main {
min-width: 0;
display: grid;
gap: 3px;
}
.reimbursement-draft-main strong {
color: #102033;
font-size: 13px;
font-weight: 850;
line-height: 1.35;
}
.reimbursement-draft-main p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.reimbursement-draft-main p span {
color: #1e293b;
font-weight: 850;
overflow-wrap: anywhere;
}
.reimbursement-draft-link {
display: inline;
margin-left: 8px;
padding: 0;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--theme-primary-active, #255b7d);
font: inherit;
font-weight: 850;
line-height: inherit;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
transition: color 0.18s ease, outline-color 0.18s ease;
}
.reimbursement-draft-link:hover:not(:disabled) {
color: var(--theme-primary, #3a7ca5);
}
.reimbursement-draft-link:focus-visible {
outline: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
outline-offset: 2px;
}
.reimbursement-draft-link:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.application-draft-preview .application-draft-head {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;

View File

@@ -1271,6 +1271,11 @@
}
@media (max-width: 860px) {
.skill-center {
height: auto;
min-height: 100%;
}
.skill-list,
.detail-card,
.side-card,
@@ -1278,6 +1283,25 @@
padding: 16px;
}
.skill-list {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.skill-list .status-tabs {
flex-wrap: nowrap;
padding-bottom: 10px;
overflow-x: auto;
scrollbar-width: thin;
}
.skill-list .status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.list-toolbar,
.card-head,
.detail-actions,
@@ -1306,6 +1330,102 @@
width: min(100vw - 64px, 320px);
}
.skill-list .table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.skill-list .table-wrap table,
.skill-list .table-wrap thead,
.skill-list .table-wrap tbody,
.skill-list .table-wrap tr,
.skill-list .table-wrap th,
.skill-list .table-wrap td {
display: block;
}
.skill-list .table-wrap table {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.skill-list .table-wrap thead {
display: none;
}
.skill-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.skill-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.skill-list .table-wrap td,
.audit-asset-table td:first-child,
.audit-asset-table td:not(:first-child) {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.skill-list .table-wrap td:last-child {
border-bottom: 0;
}
.skill-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.skill-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.skill-list .table-wrap td:first-child::before {
display: none;
}
.skill-name-cell {
width: 100%;
}
.skill-name-cell span:last-child {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.list-foot {
justify-content: stretch;
}
.hero-stats,
.form-grid,
.contract-grid {
@@ -1618,3 +1738,91 @@
grid-column: span 1;
}
}
@media (max-width: 860px) {
.skill-center :deep(.skill-list .table-wrap) {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.skill-center :deep(.skill-list .table-wrap table),
.skill-center :deep(.skill-list .table-wrap thead),
.skill-center :deep(.skill-list .table-wrap tbody),
.skill-center :deep(.skill-list .table-wrap tr),
.skill-center :deep(.skill-list .table-wrap th),
.skill-center :deep(.skill-list .table-wrap td) {
display: block;
}
.skill-center :deep(.skill-list .table-wrap table) {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.skill-center :deep(.skill-list .table-wrap thead) {
display: none;
}
.skill-center :deep(.skill-list .table-wrap tbody) {
display: grid;
gap: 10px;
}
.skill-center :deep(.skill-list .table-wrap tr) {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.skill-center :deep(.skill-list .table-wrap td) {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.skill-center :deep(.skill-list .table-wrap td:last-child) {
border-bottom: 0;
}
.skill-center :deep(.skill-list .table-wrap td::before) {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.skill-center :deep(.skill-list .table-wrap td:first-child) {
grid-template-columns: 1fr;
padding-top: 0;
}
.skill-center :deep(.skill-list .table-wrap td:first-child::before) {
display: none;
}
.skill-center :deep(.skill-name-cell span:last-child) {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
}

View File

@@ -500,3 +500,49 @@
padding: 8px 16px;
}
}
@media (max-width: 760px) {
.budget-dialog-backdrop {
align-items: stretch;
justify-content: stretch;
padding: 0;
}
.budget-edit-dialog {
width: 100vw;
height: 100dvh;
max-height: 100dvh;
border-radius: 0;
}
.budget-edit-head {
min-height: 52px;
padding: 0 14px;
}
.budget-edit-body {
padding: 14px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.budget-edit-section + .budget-edit-section {
margin-top: 14px;
}
.budget-edit-table-wrap {
flex: 0 0 auto;
max-height: none;
}
.budget-edit-total {
height: auto;
min-height: 42px;
justify-content: space-between;
padding: 8px 12px;
}
.budget-edit-foot {
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
}
}

View File

@@ -445,12 +445,23 @@
}
@media (max-width: 760px) {
.budget-center-page {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.budget-list {
padding: 16px;
height: auto;
min-height: 100%;
display: block;
overflow: visible;
padding: 12px;
}
.budget-detail-page {
padding: 16px 16px 0;
padding: 12px 12px 0;
}
.budget-select-filter,
@@ -464,6 +475,121 @@
justify-content: space-between;
}
.budget-scope-tabs {
gap: 18px;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.budget-scope-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.budget-toolbar .document-actions {
width: 100%;
justify-content: stretch;
}
.budget-table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.budget-list-table,
.budget-list-table thead,
.budget-list-table tbody,
.budget-list-table tr,
.budget-list-table th,
.budget-list-table td {
display: block;
}
.budget-list-table,
.budget-list-table.all,
.budget-list-table.review,
.budget-list-table.archive {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.budget-list-table thead,
.budget-list-table colgroup {
display: none;
}
.budget-list-table tbody {
display: grid;
gap: 10px;
}
.budget-list-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.budget-list-table td {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.budget-list-table td:last-child {
border-bottom: 0;
}
.budget-list-table td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.budget-list-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.budget-list-table td:first-child::before {
display: none;
}
.budget-rate {
max-width: none;
justify-content: flex-start;
}
.budget-detail-page :deep(.detail-scroll) {
overflow: visible;
padding-right: 0;
}
.budget-detail-page :deep(.detail-grid) {
display: block;
}
.budget-period-grid {
grid-template-columns: 1fr;
}
@@ -471,4 +597,71 @@
.budget-status-explain-list {
grid-template-columns: minmax(0, 1fr);
}
.budget-detail-table-wrap {
overflow: visible;
}
.budget-detail-table,
.budget-detail-table thead,
.budget-detail-table tbody,
.budget-detail-table tr,
.budget-detail-table th,
.budget-detail-table td {
display: block;
}
.budget-detail-table {
min-width: 0;
border-collapse: separate;
}
.budget-detail-table thead {
display: none;
}
.budget-detail-table tbody {
display: grid;
gap: 10px;
}
.budget-detail-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.budget-detail-table td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border: 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
}
.budget-detail-table td:last-child {
border-bottom: 0;
}
.budget-detail-table td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.budget-detail-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.budget-detail-table td:first-child::before {
display: none;
}
}

View File

@@ -168,3 +168,137 @@
justify-content: center;
}
}
@media (max-width: 760px) {
.digital-employees-list {
padding: 12px 0;
overflow: auto;
}
.digital-employees-list > .status-tabs {
gap: 18px;
margin-top: 10px;
padding: 0 12px 1px;
}
.digital-employee-list-panel .table-wrap {
flex: 0 0 auto;
min-height: 0;
margin-top: 12px;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.digital-employee-list-panel .list-foot {
flex: 0 0 auto;
padding: 0 12px;
}
.digital-employees-table,
.digital-employees-table tbody,
.digital-employees-table tr,
.digital-employees-table td {
display: block;
}
.digital-employees-table {
width: 100%;
min-width: 0;
border-collapse: separate;
border-spacing: 0;
}
.digital-employees-table colgroup,
.digital-employees-table thead {
display: none;
}
.digital-employees-table tbody {
display: grid;
gap: 10px;
}
.digital-employees-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
}
.digital-employees-table td {
min-height: 34px;
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 7px 0;
border: 0;
border-bottom: 1px dashed #e2e8f0;
color: #334155;
text-align: left;
white-space: normal;
}
.digital-employees-table td:last-child {
border-bottom: 0;
}
.digital-employees-table td::before {
color: #64748b;
font-size: 12px;
font-weight: 760;
line-height: 1.4;
}
.digital-employees-table td[data-label]::before {
content: attr(data-label);
}
.digital-employees-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.digital-employees-table td:first-child::before {
display: none;
}
.digital-employees-table .doc-kind-tag,
.digital-employees-table .type-tag,
.digital-employees-table .status-tag {
width: fit-content;
max-width: 100%;
justify-self: start;
white-space: normal;
}
.digital-skill-cell {
grid-template-columns: 38px minmax(0, 1fr);
}
.digital-skill-cell .doc-id {
white-space: normal;
}
}
@media (max-width: 480px) {
.digital-employees-table td {
grid-template-columns: 76px minmax(0, 1fr);
gap: 8px;
}
.digital-skill-cell {
grid-template-columns: 34px minmax(0, 1fr);
gap: 8px;
}
.digital-skill-avatar {
width: 34px;
height: 34px;
border-radius: 8px;
}
}

View File

@@ -222,8 +222,19 @@
}
@media (max-width: 760px) {
.documents-page {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.documents-list {
padding: 16px;
height: auto;
min-height: 100%;
display: block;
overflow: visible;
padding: 12px;
}
.document-status-filter {

View File

@@ -1202,6 +1202,13 @@ td.cell-updated {
}
@media (max-width: 860px) {
.employee-center,
.employee-list,
.employee-detail {
height: auto;
min-height: 100%;
}
.employee-list,
.detail-card,
.side-card,
@@ -1209,6 +1216,15 @@ td.cell-updated {
padding: 16px;
}
.employee-center,
.employee-list {
overflow: visible;
}
.employee-list {
display: block;
}
.list-toolbar,
.card-head,
.detail-actions,
@@ -1223,6 +1239,21 @@ td.cell-updated {
overflow-x: auto;
}
.status-tabs {
gap: 18px;
flex-wrap: nowrap;
scrollbar-width: thin;
}
.status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.status-tabs button span {
white-space: nowrap;
}
.list-foot {
grid-template-columns: 1fr;
justify-items: stretch;
@@ -1237,6 +1268,20 @@ td.cell-updated {
width: 100%;
}
.toolbar-actions {
width: 100%;
flex-wrap: wrap;
}
.ghost-filter-btn,
.template-btn,
.export-btn,
.create-btn {
flex: 1 1 140px;
justify-content: center;
white-space: nowrap;
}
.picker-popover {
width: min(280px, calc(100vw - 64px));
}
@@ -1246,6 +1291,18 @@ td.cell-updated {
justify-self: stretch;
}
.pager {
width: 100%;
max-width: 100%;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: thin;
}
.pager button {
flex: 0 0 auto;
}
.hero-stats,
.form-grid,
.role-grid {
@@ -1265,4 +1322,99 @@ td.cell-updated {
.history-row-time {
text-align: left;
}
.employee-list .table-wrap {
flex: none;
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.employee-list .table-wrap table,
.employee-list .table-wrap thead,
.employee-list .table-wrap tbody,
.employee-list .table-wrap tr,
.employee-list .table-wrap th,
.employee-list .table-wrap td {
display: block;
}
.employee-list .table-wrap table {
min-width: 0;
width: 100%;
border-collapse: separate;
}
.employee-list .table-wrap thead,
.employee-list .table-wrap colgroup {
display: none;
}
.employee-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.employee-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.employee-list .table-wrap tr:hover {
background: #f8fbff;
}
.employee-list .table-wrap td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: start;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.employee-list .table-wrap td:last-child {
border-bottom: 0;
}
.employee-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.7;
}
.employee-list .table-wrap td > * {
min-width: 0;
}
.employee-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.employee-list .table-wrap td:first-child::before {
display: none;
}
.employee-list .table-wrap .employee-cell {
grid-template-columns: 38px minmax(0, 1fr);
}
.employee-list .table-wrap .role-stack {
justify-content: flex-start;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1163,6 +1163,46 @@ th {
}
@media (max-width: 760px) {
.knowledge-page {
height: auto;
min-height: 100%;
overflow: visible;
}
.knowledge-grid,
.knowledge-main,
.library-panel,
.library-body,
.document-area {
height: auto;
min-height: 0;
overflow: visible;
}
.knowledge-grid,
.library-panel,
.library-body,
.document-area {
display: block;
}
.knowledge-grid,
.knowledge-main,
.library-panel,
.library-body,
.document-area,
.doc-table-wrap,
.folder-tree {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.library-panel {
padding: 12px;
}
.panel-title,
.preview-head,
.llm-wiki-section-head,
@@ -1177,10 +1217,134 @@ th {
width: 100%;
}
.summary-grid,
.list-foot {
grid-template-columns: 1fr;
}
.library-body {
margin-top: 12px;
}
.folder-rail {
display: block;
padding: 0 0 10px;
}
.folder-tree {
display: flex;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
scrollbar-width: thin;
}
.folder-tree button {
flex: 0 0 auto;
width: max-content;
min-width: 132px;
}
.upload-zone {
margin-bottom: 10px;
}
.doc-table-wrap {
overflow: visible;
}
.knowledge-document-table,
.knowledge-document-table thead,
.knowledge-document-table tbody,
.knowledge-document-table tr,
.knowledge-document-table th,
.knowledge-document-table td {
display: block;
}
.knowledge-document-table {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.knowledge-document-table thead {
display: none;
}
.knowledge-document-table tbody {
display: grid;
gap: 10px;
}
.knowledge-document-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.knowledge-document-table td,
.knowledge-document-table th:not(:first-child),
.knowledge-document-table td:not(:first-child),
.knowledge-document-table td:first-child {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border: 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
}
.knowledge-document-table td:last-child {
border-bottom: 0;
}
.knowledge-document-table td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.knowledge-document-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.knowledge-document-table td:first-child::before {
display: none;
}
.knowledge-document-table .empty-row {
display: block;
padding: 12px 0;
border-bottom: 0;
text-align: center;
}
.knowledge-document-table .empty-row::before {
display: none;
}
.file-name {
white-space: normal;
}
.state-cell {
justify-items: start;
}
.row-actions {
justify-content: flex-start;
}
.summary-grid,
.list-foot {
grid-template-columns: 1fr;
}
.list-foot {
gap: 12px;

View File

@@ -676,10 +676,139 @@
}
@media (max-width: 760px) {
.receipt-folder-page {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.receipt-folder-list {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
padding: 12px;
}
.receipt-status-tabs {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.receipt-status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.receipt-folder-list .document-actions,
.receipt-folder-list .filter-set,
.receipt-folder-list .list-search,
.receipt-folder-list .filter-btn,
.receipt-folder-list .create-request-btn,
.receipt-folder-list .page-size-select {
width: 100%;
}
.receipt-folder-list .table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.receipt-folder-list .table-wrap table,
.receipt-folder-list .table-wrap thead,
.receipt-folder-list .table-wrap tbody,
.receipt-folder-list .table-wrap tr,
.receipt-folder-list .table-wrap th,
.receipt-folder-list .table-wrap td {
display: block;
}
.receipt-folder-list .table-wrap table {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.receipt-folder-list .table-wrap thead,
.receipt-folder-list .table-wrap colgroup {
display: none;
}
.receipt-folder-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.receipt-folder-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.receipt-folder-list .table-wrap td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.receipt-folder-list .table-wrap td:last-child {
border-bottom: 0;
}
.receipt-folder-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.receipt-folder-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.receipt-folder-list .table-wrap td:first-child::before {
display: none;
}
.receipt-folder-list td:first-child .doc-id {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.receipt-folder-list .list-foot {
display: grid;
justify-items: stretch;
}
.receipt-folder-list .pager {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
}
.receipt-detail-toolbar,
.receipt-toolbar-actions,
.receipt-preview-tools {

View File

@@ -804,6 +804,10 @@
}
@media (max-width: 640px) {
.settings-shell {
overflow: hidden;
}
.settings-toolbar {
padding: 16px;
}
@@ -824,4 +828,26 @@
.settings-nav {
padding: 16px 12px 12px;
}
.settings-nav-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
max-height: 188px;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: 0;
scrollbar-width: thin;
}
.settings-nav-item {
min-width: 0;
min-height: 54px;
padding: 9px 10px;
}
.nav-item-copy strong,
.nav-item-copy small {
overflow: hidden;
text-overflow: ellipsis;
}
}

View File

@@ -317,41 +317,75 @@
}
@media (max-width: 760px) {
:global(.assistant-el-overlay .el-overlay-dialog) {
height: 100dvh;
max-height: 100dvh;
padding: 0;
}
:global(.assistant-el-dialog.el-dialog.is-fullscreen) {
border-radius: 0;
padding: 0;
}
.assistant-overlay {
--assistant-viewport-inset: 10px;
--assistant-viewport-inset: 0;
}
:global(.assistant-el-overlay) {
--assistant-viewport-inset: 10px;
--assistant-viewport-inset: 0;
}
.assistant-modal,
.assistant-modal-stage {
border-radius: 4px;
width: 100%;
height: 100%;
min-height: 0;
max-height: 100%;
border-radius: 0;
}
.assistant-modal-stage {
height: 100dvh;
max-height: 100dvh;
border: 0;
box-shadow: none;
background: #f6f9fc;
}
.assistant-header {
padding: 18px 18px 16px;
align-items: flex-start;
flex-direction: column;
min-height: 58px;
padding: calc(10px + env(safe-area-inset-top, 0px)) 138px 10px 12px;
align-items: center;
flex-direction: row;
border-bottom: 1px solid #e5edf5;
}
.assistant-header-actions {
top: 18px;
right: 18px;
gap: 10px;
top: calc(9px + env(safe-area-inset-top, 0px));
right: 10px;
gap: 6px;
width: auto;
justify-content: space-between;
justify-content: flex-end;
}
.assistant-toggle-btn,
.session-trash-btn,
.assistant-close-btn,
.close-btn {
width: 40px;
height: 40px;
width: 38px;
height: 38px;
border-radius: 4px;
font-size: 15px;
}
.assistant-title {
font-size: 16px;
line-height: 1.25;
}
.assistant-subtitle {
display: none;
}
.flow-step-card header {
@@ -359,16 +393,87 @@
}
.assistant-layout {
padding: 14px;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding: 0;
overflow: hidden;
}
.dialog-panel {
border: 0;
border-radius: 0;
box-shadow: none;
}
.insight-panel-shell {
position: absolute;
inset: 0;
z-index: 70;
width: 100%;
max-height: none;
margin-left: 0;
transform: translateX(100%);
pointer-events: none;
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
.assistant-layout.has-insight .insight-panel-shell {
transform: translateX(0);
pointer-events: auto;
}
.insight-panel-shell.collapsed {
width: 100%;
transform: translateX(100%);
}
.insight-panel {
border-radius: 0;
}
.composer-row {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--composer-control-size, 40px);
align-items: end;
gap: 8px;
--composer-control-size: 40px;
}
.composer-leading-actions {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
width: 100%;
}
.composer-leading-actions .composer-side-btn,
.composer-leading-actions .tool-btn {
width: 100%;
}
.composer-date-anchor,
.travel-calculator-anchor {
min-width: 0;
}
.composer-row .composer-shell {
min-width: 0;
}
.composer-shell-body {
align-items: flex-start;
padding: 6px 10px;
}
.composer-shell textarea {
min-height: 32px;
flex-basis: 100%;
min-height: 40px;
max-height: 104px;
padding: 8px 2px;
line-height: 20px;
}
.travel-calculator-form {
@@ -376,20 +481,87 @@
}
.dialog-toolbar {
padding: 16px 16px 12px;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 8px 10px;
overflow: visible;
}
.dialog-toolbar-label {
min-width: 0;
}
.shortcut-chip-wrap {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
overflow: visible;
}
.shortcut-chip {
width: 100%;
min-width: 0;
justify-content: center;
}
.shortcut-chip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-list {
padding: 16px;
padding: 12px 10px;
gap: 10px;
}
.message-avatar {
width: 32px;
height: 32px;
}
.message-bubble {
max-width: 100%;
padding: 10px 12px;
}
.message-suggested-actions {
grid-template-columns: 1fr;
}
.composer {
padding: 0 16px 16px;
gap: 8px;
padding: 8px 10px calc(10px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid #e5edf5;
background: #fff;
}
.composer-files-panel {
max-height: 30dvh;
overflow-y: auto;
padding: 10px;
}
.composer-date-popover,
.travel-calculator-popover {
position: fixed;
left: 10px;
right: 10px;
bottom: calc(150px + env(safe-area-inset-bottom, 0px));
width: auto;
max-height: min(58dvh, 420px);
overflow-y: auto;
}
.composer-date-fields-range {
grid-template-columns: 1fr;
}
.composer-date-range-sep {
display: none;
}
.composer-files-head,

View File

@@ -769,6 +769,15 @@
background: #dbe4ee;
}
.application-leader-opinion-timeline.is-single {
padding-left: 0;
}
.application-leader-opinion-timeline.is-single::before,
.application-leader-opinion-timeline.is-single .application-leader-opinion-event::before {
display: none;
}
.application-leader-opinion-event {
position: relative;
display: grid;

View File

@@ -194,7 +194,7 @@
:class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }"
@click="emit('open-asset-detail', skill)"
>
<td>
<td :data-label="tableColumns.name">
<div class="skill-name-cell">
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
<div>
@@ -203,8 +203,8 @@
</div>
</div>
</td>
<td>{{ skill.category }}</td>
<td>
<td :data-label="tableColumns.category">{{ skill.category }}</td>
<td :data-label="tableColumns.owner">
<span
v-if="skill.usesJsonRiskRule"
class="json-risk-meta-badge"
@@ -214,20 +214,20 @@
</span>
<template v-else>{{ skill.owner }}</template>
</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td v-if="showRuntimeColumn">{{ skill.model }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn">
<td :data-label="tableColumns.scope"><span class="scope-pill">{{ skill.scope }}</span></td>
<td v-if="showRuntimeColumn" :data-label="tableColumns.runtime">{{ skill.model }}</td>
<td v-if="showVersionColumn" :data-label="tableColumns.version">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn" :data-label="tableColumns.status || '状态'">
<span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span>
</td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td v-if="showOnlineColumn">
<td v-if="showMetricColumn" :data-label="tableColumns.metric">{{ skill.hitRate }}</td>
<td v-if="showOnlineColumn" data-label="是否上线">
<span class="status-pill" :class="skill.isOnlineTone">{{ skill.isOnlineLabel }}</span>
</td>
<td v-if="showEnabledColumn">
<td v-if="showEnabledColumn" data-label="是否启用">
<span class="status-pill" :class="skill.isEnabledTone">{{ skill.isEnabledLabel }}</span>
</td>
<td>{{ skill.updatedAt }}</td>
<td :data-label="tableColumns.updatedAt || '最近更新'">{{ skill.updatedAt }}</td>
</tr>
</tbody>
</table>

View File

@@ -156,15 +156,15 @@
<strong class="doc-id">{{ employee.name }}</strong>
</div>
</td>
<td><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td>
<td>{{ employee.owner }}</td>
<td><span class="type-tag other">{{ employee.scope }}</span></td>
<td>{{ employee.executionMode }}</td>
<td>
<td data-label="技能类型"><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td>
<td data-label="维护归口">{{ employee.owner }}</td>
<td data-label="执行计划"><span class="type-tag other">{{ employee.scope }}</span></td>
<td data-label="触发方式">{{ employee.executionMode }}</td>
<td data-label="资产状态">
<span :class="['status-tag', employee.statusTone]">{{ employee.status }}</span>
</td>
<td><span :class="['status-tag', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td>{{ employee.updatedAt || '-' }}</td>
<td data-label="启动状态"><span :class="['status-tag', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td data-label="最近更新">{{ employee.updatedAt || '-' }}</td>
</tr>
</tbody>
</table>
@@ -292,4 +292,21 @@ function changePageSize(size) {
overflow: hidden;
}
@media (max-width: 760px) {
.digital-employee-list-panel {
overflow: auto;
}
.digital-employee-list-panel :deep(.table-wrap) {
flex: 0 0 auto;
min-height: 0;
display: block;
overflow: visible;
}
.digital-employee-list-panel :deep(.list-foot) {
flex: 0 0 auto;
padding: 0 12px 12px;
}
}
</style>

View File

@@ -7,7 +7,7 @@
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
/>
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${homepageBackground})` }">
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy">
<h1>{{ displayUserName }}我是您的 <span>AI 费用助手</span></h1>
@@ -358,16 +358,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import homepageBackground from '../../assets/homepage_backgraound.png'
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import {
assistantCapabilities,
buildExpenseStatItems,
filterAssistantCapabilitiesForUser,
progressItems,
progressSteps,
quickPromptItems,
resolveWorkbenchCapabilityGridClass,
todoItems,
} from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js'
@@ -433,9 +434,6 @@ let employeeProfileLoadSeq = 0
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const FINANCIAL_CAPABILITY_KEYS = new Set(['budget-planning', 'finance-analysis'])
const FINANCIAL_CAPABILITY_ROLE_CODES = new Set(['budget_monitor', 'executive', 'admin'])
const FINANCIAL_CAPABILITY_ROLE_LABELS = new Set(['预算监控员', '高级财务人员', '管理员'])
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
@@ -456,28 +454,8 @@ const composerPendingLabel = computed(() => {
}
return ''
})
const currentRoleCodes = computed(() => {
const user = currentUser.value || {}
const rawCodes = Array.isArray(user.roleCodes)
? user.roleCodes
: Array.isArray(user.role_codes)
? user.role_codes
: []
return new Set(rawCodes.map((code) => String(code || '').trim().toLowerCase()).filter(Boolean))
})
const canViewFinancialCapabilities = computed(() => {
const user = currentUser.value || {}
const roleLabel = String(user.role || '').trim()
return Boolean(user.isAdmin)
|| FINANCIAL_CAPABILITY_ROLE_LABELS.has(roleLabel)
|| Array.from(currentRoleCodes.value).some((code) => FINANCIAL_CAPABILITY_ROLE_CODES.has(code))
})
const visibleAssistantCapabilities = computed(() =>
assistantCapabilities.filter((item) => canViewFinancialCapabilities.value || !FINANCIAL_CAPABILITY_KEYS.has(item.key))
)
const capabilityGridClass = computed(() =>
canViewFinancialCapabilities.value ? 'capability-grid--privileged' : 'capability-grid--standard'
)
const visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value))
const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
const visibleExpenseStatItems = computed(() => {
const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment']
@@ -817,6 +795,7 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
</script>
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-glass.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>

View File

@@ -28,7 +28,7 @@
<section class="budget-report-main">
<article class="budget-report-chart-panel">
<div class="budget-report-section-head">
<strong>上季度费用结构</strong>
<strong>{{ expenseStructureTitle }}</strong>
<span>{{ report.centerLabel }}</span>
</div>
<DonutChart
@@ -52,7 +52,7 @@
<section class="budget-report-detail-panel">
<div class="budget-report-section-head">
<strong>费用类型拆解</strong>
<span>用于编制下一季度预算</span>
<span>用于编制{{ report.periodType || '下一期预算' }}</span>
</div>
<div class="budget-report-expense-list">
<article
@@ -83,17 +83,21 @@
<section class="budget-report-editor-panel">
<div class="budget-report-section-head">
<strong>预算构成编辑</strong>
<span>{{ report.periodType || '预算' }} · 可直接调整</span>
<span>{{ editorSubtitle }}</span>
</div>
<div class="budget-editor-table" role="table" aria-label="预算构成编辑表">
<div
class="budget-editor-table"
:class="{ 'is-review': isReviewMode }"
role="table"
aria-label="预算构成编辑表"
>
<div class="budget-editor-row head" role="row">
<span role="columnheader">费用类型</span>
<span role="columnheader">编制金额</span>
<span role="columnheader">提醒</span>
<span role="columnheader">告警</span>
<span role="columnheader">风险</span>
<span role="columnheader">预算金额</span>
<span v-if="isReviewMode" role="columnheader">建议预算</span>
<span role="columnheader">预算说明</span>
<span v-if="isReviewMode" role="columnheader">建议</span>
</div>
<div
@@ -104,43 +108,50 @@
>
<strong role="cell">{{ row.name }}</strong>
<label role="cell">
<span>金额</span>
<input v-model.number="row.budgetAmount" type="number" min="0" step="1000" />
<span>预算金额</span>
<input
v-model.number="row.budgetAmount"
type="number"
min="0"
step="1000"
:readonly="isReviewMode"
/>
</label>
<label role="cell">
<span>提醒</span>
<input v-model.number="row.reminderThreshold" type="number" min="0" max="100" step="1" />
<label v-if="isReviewMode" role="cell">
<span>建议预算</span>
<input v-model.number="row.suggestedBudget" type="number" min="0" step="1000" />
</label>
<label role="cell">
<span>告警</span>
<input v-model.number="row.alertThreshold" type="number" min="0" max="100" step="1" />
<label class="budget-editor-note-cell" role="cell">
<span>预算说明</span>
<textarea v-model="row.submittedNote" :readonly="isReviewMode" rows="2" />
</label>
<label role="cell">
<span>风险</span>
<input v-model.number="row.riskThreshold" type="number" min="0" max="100" step="1" />
<label v-if="isReviewMode" class="budget-editor-note-cell" role="cell">
<span>建议</span>
<textarea v-model="row.financeSuggestion" rows="2" />
</label>
<textarea v-model="row.note" role="cell" rows="2" />
</div>
</div>
<footer class="budget-editor-footer">
<div>
<span>当前编制总额</span>
<span>{{ totalLabel }}</span>
<strong>{{ editableTotalDisplay }}</strong>
<small>{{ draftStatusText }}</small>
</div>
<button type="button" class="budget-editor-secondary" @click="applyRecommendedBudget">
应用建议
</button>
<button type="button" class="budget-editor-primary" @click="generateBudgetDraft">
生成预算草案
<button
type="button"
class="budget-editor-primary"
:class="{ danger: isReviewMode && hasReviewChanges }"
@click="submitBudgetEditorAction"
>
{{ primaryActionLabel }}
</button>
</footer>
</section>
<section class="budget-report-action-panel">
<div>
<strong>编制建议</strong>
<strong>{{ recommendationTitle }}</strong>
<p v-for="item in report.recommendations" :key="item">{{ item }}</p>
</div>
<span>{{ report.generatedAt }}</span>
@@ -162,6 +173,7 @@ const props = defineProps({
const draftRows = reactive([])
const draftStatus = ref('editing')
const initialReviewSnapshot = ref('')
const formatAmount = (value) =>
`¥${Number(value || 0).toLocaleString('zh-CN', {
@@ -177,44 +189,94 @@ function resetDraftRows() {
key: item.key,
name: item.name,
budgetAmount: Number(item.budgetAmount ?? item.recommendedBudget ?? 0),
reminderThreshold: Number(item.reminderThreshold ?? 70),
alertThreshold: Number(item.alertThreshold ?? 80),
riskThreshold: Number(item.riskThreshold ?? 90),
note: String(item.note || item.suggestion || '')
suggestedBudget: Number(item.suggestedBudget ?? item.recommendedBudget ?? item.budgetAmount ?? 0),
submittedNote: String(item.submittedNote || item.note || item.suggestion || ''),
financeSuggestion: String(item.financeSuggestion || '')
})))
)
draftStatus.value = 'editing'
initialReviewSnapshot.value = buildReviewSnapshot()
}
watch(() => props.report, resetDraftRows, { immediate: true })
const editableTotalDisplay = computed(() =>
formatAmount(draftRows.reduce((sum, item) => sum + Number(item.budgetAmount || 0), 0))
formatAmount(draftRows.reduce((sum, item) => {
const value = isReviewMode.value ? item.suggestedBudget : item.budgetAmount
return sum + Number(value || 0)
}, 0))
)
const isReviewMode = computed(() =>
props.report.mode === 'review' || props.report.editableDraft?.mode === 'review'
)
const editorSubtitle = computed(() =>
isReviewMode.value
? '高级财务审核 · 修改建议预算或建议后将回退预算'
: `${props.report.periodType || '预算'} · 仅编辑本部门预算`
)
const totalLabel = computed(() => isReviewMode.value ? '建议预算总额' : '当前编制总额')
function buildReviewSnapshot() {
return JSON.stringify(draftRows.map((row) => ({
key: row.key,
suggestedBudget: Number(row.suggestedBudget || 0),
financeSuggestion: String(row.financeSuggestion || '').trim()
})))
}
const hasReviewChanges = computed(() =>
isReviewMode.value && buildReviewSnapshot() !== initialReviewSnapshot.value
)
const draftStatusText = computed(() =>
draftStatus.value === 'generated'
? '已生成本轮预算草案,后续可提交高级财务审核'
: '调整后可生成预算草案'
draftStatus.value === 'returned'
? '已标记回退预算,请预算管理者按建议调整后再次提交'
: draftStatus.value === 'formed'
? '已形成预算,可进入预算中心正式生效'
: isReviewMode.value
? '未调整建议时可形成预算;调整后将回退预算'
: '保存后提交高级财务人员审核'
)
function applyRecommendedBudget() {
resetDraftRows()
}
const baseBudgetLabel = computed(() =>
isReviewMode.value
? '提交预算'
: props.report.periodType === '年度预算' ? '去年预算' : '上季度预算'
)
function generateBudgetDraft() {
draftStatus.value = 'generated'
const expenseStructureTitle = computed(() =>
isReviewMode.value
? '提交预算费用结构'
: props.report.periodType === '年度预算' ? '去年费用结构' : '上季度费用结构'
)
const recommendationTitle = computed(() => isReviewMode.value ? '审核建议' : '编制建议')
const primaryActionLabel = computed(() => {
if (!isReviewMode.value) return '保存预算'
return hasReviewChanges.value ? '回退预算' : '形成预算'
})
function submitBudgetEditorAction() {
if (!isReviewMode.value) {
draftStatus.value = 'formed'
return
}
draftStatus.value = hasReviewChanges.value ? 'returned' : 'formed'
}
const summaryCards = computed(() => [
{
label: '上季度预算',
label: baseBudgetLabel.value,
value: props.report.summary?.totalBudget || '—',
hint: '作为编制基准',
color: 'var(--theme-primary)'
},
{
label: '上季度开销',
label: props.report.centerLabel || '上季度开销',
value: props.report.summary?.totalSpend || '—',
hint: '按四类预算口径汇总',
color: 'var(--theme-secondary)'
@@ -396,12 +458,21 @@ const summaryCards = computed(() => [
.budget-editor-row {
display: grid;
grid-template-columns: minmax(64px, .7fr) minmax(118px, .95fr) repeat(3, minmax(68px, .55fr)) minmax(220px, 1.6fr);
grid-template-columns: minmax(82px, .7fr) minmax(128px, .9fr) minmax(280px, 2fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.budget-editor-table.is-review .budget-editor-row {
grid-template-columns:
minmax(82px, .65fr)
minmax(122px, .75fr)
minmax(122px, .8fr)
minmax(240px, 1.6fr)
minmax(240px, 1.6fr);
}
.budget-editor-row.head {
min-height: 34px;
padding: 0 8px;
@@ -465,6 +536,13 @@ const summaryCards = computed(() => [
outline: 3px solid var(--theme-focus-ring, rgba(58, 124, 165, .12));
}
.budget-editor-row input[readonly],
.budget-editor-row textarea[readonly] {
background: #f8fafc;
color: #475569;
cursor: default;
}
.budget-editor-footer {
margin-top: 10px;
padding-top: 10px;
@@ -510,6 +588,10 @@ const summaryCards = computed(() => [
color: #fff;
}
.budget-editor-primary.danger {
background: #7f1d1d;
}
.budget-editor-secondary {
border: 1px solid #d7e0ea;
background: #fff;

View File

@@ -17,13 +17,13 @@
<strong>{{ decisionTitle }}</strong>
<p>{{ decisionDescription }}</p>
</div>
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
</div>
<div class="employee-risk-action">
<span>建议动作</span>
<strong :class="decisionTone">{{ decisionAction }}</strong>
</div>
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
</div>
</section>
<section class="employee-risk-profile-section">
@@ -315,8 +315,20 @@ function normalizeBusinessStage(value) {
function resolveReimbursementMaterialIssues(items) {
return items
.filter((item) => !item?.isSystemGenerated && !String(item?.invoiceId || '').trim())
.map((item) => `未上传票据${item.name || item.category || item.desc || '未命名明细'}`)
.filter((item) => !item?.isSystemGenerated && isRequiredMaterialItem(item) && !String(item?.invoiceId || '').trim())
.map((item) => `住宿材料待补充${item.name || item.category || item.desc || '住宿明细'}`)
}
function isRequiredMaterialItem(item) {
const text = [
item?.itemType,
item?.typeCode,
item?.name,
item?.category,
item?.desc,
item?.itemReason
].map((value) => String(value || '').trim()).join(' ')
return /hotel_ticket|hotel|住宿|酒店|水单/.test(text)
}
function resolveSceneIssues(request, items, isApplicationDocument) {
@@ -522,7 +534,7 @@ function uniqueTexts(values) {
.employee-risk-ai-note {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 38%);
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 10px 12px;
@@ -568,14 +580,18 @@ function uniqueTexts(values) {
}
.employee-risk-action {
grid-column: 1 / -1;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
text-align: center;
}
.employee-risk-action span {
@@ -592,6 +608,7 @@ function uniqueTexts(values) {
font-size: 12px;
font-weight: 800;
line-height: 1.5;
text-align: center;
}
.employee-risk-action strong.medium {

View File

@@ -333,6 +333,66 @@
</p>
</div>
<div
v-if="message.role === 'assistant' && ui.shouldShowDraftSavedCard(message)"
class="draft-preview application-draft-preview"
:class="{ 'reimbursement-draft-preview': !ui.isApplicationDraftPayload(message.draftPayload) }"
>
<template v-if="ui.isApplicationDraftPayload(message.draftPayload)">
<header class="application-draft-head">
<span class="application-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-check-outline"></i>
</span>
<span class="application-draft-title">
<strong>申请单据已生成</strong>
<small>已为本次业务生成申请单请按需查看完整详情</small>
</span>
<span class="application-draft-status">{{ ui.resolveApplicationDraftStatusLabel(message.draftPayload) }}</span>
</header>
<div class="application-draft-brief" role="group" aria-label="申请单据简要信息">
<div
v-for="item in ui.buildApplicationDraftSummaryItems(message.draftPayload)"
:key="`${message.id}-application-draft-${item.label}`"
class="application-draft-brief-item"
:class="{ 'is-primary': item.label === '单号' }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<footer class="application-draft-footer">
<p>
完整审批链附件和明细可在单据详情中
<button
type="button"
class="application-draft-detail-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看</button>
</p>
</footer>
</template>
<template v-else>
<div class="reimbursement-draft-card" role="group" aria-label="报销草稿已生成">
<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>
<p>
单号<span>{{ ui.resolveReimbursementDraftClaimNo(message.draftPayload) }}</span>
<button
type="button"
class="reimbursement-draft-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看详情</button>
</p>
</div>
</div>
</template>
</div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
<div class="review-plain-followup">
<template
@@ -405,54 +465,6 @@
</div>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload"
class="draft-preview"
:class="{ 'application-draft-preview': ui.isApplicationDraftPayload(message.draftPayload) }"
>
<template v-if="ui.isApplicationDraftPayload(message.draftPayload)">
<header class="application-draft-head">
<span class="application-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-check-outline"></i>
</span>
<span class="application-draft-title">
<strong>申请单据已生成</strong>
<small>已为本次业务生成申请单请按需查看完整详情</small>
</span>
<span class="application-draft-status">{{ ui.resolveApplicationDraftStatusLabel(message.draftPayload) }}</span>
</header>
<div class="application-draft-brief" role="group" aria-label="申请单据简要信息">
<div
v-for="item in ui.buildApplicationDraftSummaryItems(message.draftPayload)"
:key="`${message.id}-application-draft-${item.label}`"
class="application-draft-brief-item"
:class="{ 'is-primary': item.label === '单号' }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<footer class="application-draft-footer">
<p>
完整审批链附件和明细可在单据详情中
<button
type="button"
class="application-draft-detail-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看</button>
</p>
</footer>
</template>
<template v-else>
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
</template>
</div>
<div v-if="message.attachments?.length" class="message-files">
<span v-for="file in message.attachments" :key="file" class="file-chip">
<i class="mdi mdi-paperclip"></i>

View File

@@ -33,7 +33,8 @@ export function useAppShell() {
files: [],
conversation: null,
scope: null,
sessionType: ''
sessionType: '',
budgetContext: null
})
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
@@ -183,7 +184,8 @@ export function useAppShell() {
files: [],
conversation: null,
scope: null,
sessionType: ''
sessionType: '',
budgetContext: null
}
smartEntrySessionId.value += 1
}
@@ -337,7 +339,10 @@ export function useAppShell() {
files: Array.isArray(payload.files) ? payload.files : [],
conversation,
scope,
sessionType
sessionType,
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
? payload.budgetContext
: null
}
smartEntrySessionId.value += 1
}
@@ -358,7 +363,7 @@ export function useAppShell() {
return
}
smartEntryOpen.value = false
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
toast(`${claimNo || '该'}单据已提交审批${approvalStage ? `,当前节点:${approvalStage}` : ''}`)
router.push({ name: 'app-documents' })
return
}

View File

@@ -5,26 +5,29 @@ export function useLoginView() {
const password = ref('')
const tenant = ref('远光软件股份有限公司')
const remember = ref(true)
const showPassword = ref(false)
const tenantOptions = [
{
label: '远光软件股份有限公司',
value: '远光软件股份有限公司'
}
]
const features = [
{
title: '智能审单',
desc: 'AI 自动识别票据与规则,提升准确率与处理效率',
icon: 'mdi mdi-file-document-outline',
tone: 'green'
iconKey: 'recognition',
title: '智能识别 自动归集',
desc: '票据智能识别,自动归集费用,减少人工录入'
},
{
title: '异常预警',
desc: '多维风险识别与预警,主动防控报销风险',
icon: 'mdi mdi-bell-outline',
tone: 'red'
iconKey: 'workflow',
title: '流程透明 合规可控',
desc: '内置审批规则引擎,流程透明,风险可控'
},
{
title: 'SLA 监控',
desc: '实时监控服务水位,保障审批和处理时效',
icon: 'mdi mdi-sync',
tone: 'blue'
iconKey: 'insight',
title: '数据洞察 决策支持',
desc: '多维度费用分析,洞察业务,驱动决策'
}
]
@@ -49,8 +52,8 @@ export function useLoginView() {
LogoMark,
password,
remember,
showPassword,
tenant,
tenantOptions,
username
}
}

View File

@@ -50,7 +50,6 @@ const ARCHIVED_STEP_LABEL = '已归档'
const REIMBURSEMENT_PROGRESS_LABELS = [
RELATED_APPLICATION_STEP_LABEL,
'待提交',
'AI预审',
'直属领导审批',
'财务审批',
'待付款',
@@ -301,11 +300,11 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
const rawNode = String(claim?.approval_stage || '').trim()
if (rawNode) {
if (rawNode === '审批流转') {
return 'AI预审'
if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
}
if (rawNode === '待补充') {
return approvalMeta.key === 'draft' ? '待提交' : 'AI预审'
return '待提交'
}
return rawNode
}
@@ -323,7 +322,7 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
return isApplicationDocument ? '审批完成' : normalizedStatus === 'paid' ? '已付款' : '归档入账'
}
return isApplicationDocument ? '直属领导审批' : 'AI预审'
return '直属领导审批'
}
function stringifyRiskFlag(value) {
@@ -375,24 +374,24 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') {
return 7
return 6
}
if (approvalMeta.key === 'pending_payment') {
return 5
return 4
}
if (normalizedNode.includes('已付款')) {
return 6
}
if (normalizedNode.includes('待付款')) {
return 5
}
if (normalizedNode.includes('待付款')) {
return 4
}
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
return 7
return 6
}
if (normalizedNode.includes('财务')) {
return 4
return 3
}
if (
normalizedNode.includes('直属领导')
@@ -400,10 +399,10 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|| normalizedNode.includes('部门负责人')
|| normalizedNode.includes('负责人审批')
) {
return 3
return 2
}
if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
return 2
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? 1 : 2
}
if (normalizedNode.includes('待提交')) {
return 1
@@ -839,11 +838,6 @@ function buildCompletedStepMeta(claim, label) {
return buildProgressStepMeta(`${employeeName}提交`, submittedAt)
}
if (stepLabel === 'AI预审') {
const reviewedAt = formatDateTime(claim?.submitted_at || claim?.updated_at)
return buildProgressStepMeta('AI预审通过', reviewedAt)
}
if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
if (approvalEvent) {
@@ -925,9 +919,6 @@ function resolveCurrentStepStartedAt(claim, label) {
const returnEvent = findLatestReturnEvent(claim)
return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at
}
if (stepLabel === 'AI预审') {
return claim?.updated_at || claim?.submitted_at || claim?.created_at
}
if (stepLabel === '直属领导审批') {
return claim?.submitted_at || claim?.updated_at || claim?.created_at
}

View File

@@ -62,6 +62,35 @@ export const assistantCapabilities = [
}
]
const FINANCIAL_CAPABILITY_KEYS = new Set(['budget-planning', 'finance-analysis'])
const FINANCIAL_CAPABILITY_ROLE_CODES = new Set(['budget_monitor', 'executive', 'admin'])
const FINANCIAL_CAPABILITY_ROLE_LABELS = new Set(['预算监控员', '高级财务人员', '管理员'])
function normalizeRoleCodes(user = {}) {
const rawCodes = Array.isArray(user.roleCodes)
? user.roleCodes
: Array.isArray(user.role_codes)
? user.role_codes
: []
return rawCodes.map((code) => String(code || '').trim().toLowerCase()).filter(Boolean)
}
export function canViewFinancialWorkbenchCapabilities(user = {}) {
const roleLabel = String(user.role || '').trim()
return Boolean(user.isAdmin)
|| FINANCIAL_CAPABILITY_ROLE_LABELS.has(roleLabel)
|| normalizeRoleCodes(user).some((code) => FINANCIAL_CAPABILITY_ROLE_CODES.has(code))
}
export function filterAssistantCapabilitiesForUser(user = {}) {
const canViewFinancial = canViewFinancialWorkbenchCapabilities(user)
return assistantCapabilities.filter((item) => canViewFinancial || !FINANCIAL_CAPABILITY_KEYS.has(item.key))
}
export function resolveWorkbenchCapabilityGridClass(user = {}) {
return canViewFinancialWorkbenchCapabilities(user) ? 'capability-grid--privileged' : 'capability-grid--standard'
}
export const todoItems = [
{
title: '待补材料',

View File

@@ -7,6 +7,7 @@ import 'element-plus/theme-chalk/el-dialog.css'
import 'element-plus/theme-chalk/el-dropdown.css'
import 'element-plus/theme-chalk/el-dropdown-item.css'
import 'element-plus/theme-chalk/el-dropdown-menu.css'
import 'element-plus/theme-chalk/el-icon.css'
import 'element-plus/theme-chalk/el-input.css'
import 'element-plus/theme-chalk/el-option.css'
import 'element-plus/theme-chalk/el-option-group.css'

View File

@@ -157,13 +157,6 @@ export function submitExpenseClaim(claimId) {
})
}
export function preReviewExpenseClaim(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, {
method: 'POST',
body: JSON.stringify({})
})
}
export function returnExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/return`, {
method: 'POST',

View File

@@ -55,6 +55,9 @@ export function writeDocumentScope(scope, allowedScopes = [], storage = getStora
}
export function isNewDocument(row, viewedKeys) {
if (row?.isNewDocument === false || row?.archived === true || String(row?.source || '').trim() === 'archive') {
return false
}
const key = resolveDocumentNewKey(row)
return Boolean(key) && !viewedKeys.has(key)
}

View File

@@ -16,7 +16,7 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'department', label: '部门', editable: false, required: false },
{ key: 'position', label: '岗位', editable: false, required: false },
{ key: 'managerName', label: '直属领导', editable: false, required: false },
{ key: 'time', label: '发生时间' },
{ key: 'time', label: '申请时间' },
{ key: 'location', label: '地点' },
{ key: 'reason', label: '事由' },
{ key: 'days', label: '天数' },
@@ -33,6 +33,20 @@ export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
export function resolveApplicationTimeLabel(applicationType = '') {
const label = String(applicationType || '').trim()
if (/差旅|出差/.test(label)) return '行程时间'
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
return '申请时间'
}
function resolveApplicationFieldLabel(item, fields = {}) {
if (item.key === 'time') {
return resolveApplicationTimeLabel(fields.applicationType)
}
return item.label
}
function compactText(value) {
return String(value || '').replace(/\s+/g, '')
}
@@ -374,7 +388,7 @@ function buildMissingFields(fields) {
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
.filter((item) => item.key !== 'applicationType' && item.required !== false)
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
.map((item) => item.label)
.map((item) => resolveApplicationFieldLabel(item, fields))
}
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
@@ -551,6 +565,38 @@ export function normalizeApplicationPreview(preview = {}) {
}
}
export function applyApplicationBusinessTimeContext(preview = {}, businessTimeContext = null) {
if (!businessTimeContext || typeof businessTimeContext !== 'object') {
return normalizeApplicationPreview(preview)
}
const startDate = String(businessTimeContext.start_date || '').trim()
const endDate = String(businessTimeContext.end_date || startDate).trim()
const displayValue = String(
businessTimeContext.business_time ||
businessTimeContext.time_range ||
businessTimeContext.display_value ||
''
).trim()
const time = startDate && endDate
? (startDate === endDate ? startDate : `${startDate}${endDate}`)
: displayValue
if (!time) {
return normalizeApplicationPreview(preview)
}
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return normalizeApplicationPreview({
...normalized,
fields: {
...fields,
time,
days: resolveDaysFromDateRange(time) || fields.days
}
})
}
export function buildModelRefinedApplicationPreview(localPreview = {}, ontology = {}, rawText = '', currentUser = {}) {
const currentFields = localPreview?.fields || {}
const ontologyFields = buildApplicationFieldsFromOntology(ontology || {}, rawText, currentUser)
@@ -598,6 +644,7 @@ export function buildApplicationPreviewRows(preview = {}) {
const value = String(rawValue || '').trim() || '待补充'
return {
...item,
label: resolveApplicationFieldLabel(item, fields),
value,
editable: item.editable !== false,
highlight: Boolean(item.highlight),

View File

@@ -8,6 +8,15 @@
}"
>
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
<button
type="button"
class="mobile-hamburger-btn"
aria-label="打开移动端导航"
:aria-expanded="mobileSidebarOpen ? 'true' : 'false'"
@click="mobileSidebarOpen = true"
>
<i class="mdi mdi-menu" aria-hidden="true"></i>
</button>
<Transition name="login-entry-veil">
<div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台">
<FloatingLightBandWindow
@@ -178,6 +187,7 @@
:initial-files="smartEntryContext.files"
:initial-conversation="smartEntryContext.conversation"
:initial-session-type="smartEntryContext.sessionType"
:initial-budget-context="smartEntryContext.budgetContext"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"

View File

@@ -150,46 +150,46 @@
<tbody>
<tr v-for="row in visibleBudgetRows" :key="row.id" @click="handleRowAction(row)">
<template v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td>
<td>{{ row.periodLabel }}</td>
<td>{{ row.annualAmountLabel }}</td>
<td>{{ row.quarterAmountLabel }}</td>
<td>{{ row.monthAmountLabel }}</td>
<td>{{ row.availableAmountLabel }}</td>
<td>
<td data-label="预算编号"><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td data-label="部门">{{ row.departmentName }}</td>
<td data-label="预算周期">{{ row.periodLabel }}</td>
<td data-label="年度预算">{{ row.annualAmountLabel }}</td>
<td data-label="季度预算">{{ row.quarterAmountLabel }}</td>
<td data-label="月度预算">{{ row.monthAmountLabel }}</td>
<td data-label="剩余可用">{{ row.availableAmountLabel }}</td>
<td data-label="使用率">
<div class="budget-rate">
<div><em :class="row.riskTone" :style="{ width: `${Math.min(row.usageRate, 100)}%` }"></em></div>
<span>{{ row.usageRateLabel }}</span>
</div>
</td>
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td>{{ row.updatedAt }}</td>
<td data-label="风险"><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td data-label="更新时间">{{ row.updatedAt }}</td>
</template>
<template v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td>
<td>{{ row.compiler }}</td>
<td>{{ row.submittedAt }}</td>
<td>{{ row.periodLabel }}</td>
<td>{{ row.requestedAmountLabel }}</td>
<td><span class="budget-change">{{ row.changeRateLabel }}</span></td>
<td><span class="budget-score">{{ row.aiScore }}</span></td>
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
<td data-label="草稿编号"><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td data-label="提交部门">{{ row.departmentName }}</td>
<td data-label="编制人">{{ row.compiler }}</td>
<td data-label="提交时间">{{ row.submittedAt }}</td>
<td data-label="预算周期">{{ row.periodLabel }}</td>
<td data-label="申请预算">{{ row.requestedAmountLabel }}</td>
<td data-label="较上一版"><span class="budget-change">{{ row.changeRateLabel }}</span></td>
<td data-label="AI 分析"><span class="budget-score">{{ row.aiScore }}</span></td>
<td data-label="风险"><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td data-label="状态"><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
</template>
<template v-else>
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td>
<td>{{ row.periodLabel }}</td>
<td>{{ row.version }}</td>
<td>{{ row.archiveType }}</td>
<td>{{ row.quarterAmountLabel }}</td>
<td>{{ row.reviewer }}</td>
<td>{{ row.archivedAt }}</td>
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
<td data-label="归档编号"><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td data-label="部门">{{ row.departmentName }}</td>
<td data-label="预算周期">{{ row.periodLabel }}</td>
<td data-label="版本">{{ row.version }}</td>
<td data-label="归档类型">{{ row.archiveType }}</td>
<td data-label="原预算额">{{ row.quarterAmountLabel }}</td>
<td data-label="审核人">{{ row.reviewer }}</td>
<td data-label="归档时间">{{ row.archivedAt }}</td>
<td data-label="状态"><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
</template>
</tr>
</tbody>
@@ -297,15 +297,15 @@
</thead>
<tbody>
<tr v-for="item in selectedBudget.categoryRows" :key="item.code">
<td><strong>{{ item.name }}</strong></td>
<td>{{ item.amountLabel }}</td>
<td>{{ item.usedLabel }}</td>
<td>{{ item.occupiedLabel }}</td>
<td>{{ item.availableLabel }}</td>
<td>{{ item.usageRateLabel }}</td>
<td><span class="budget-threshold reminder">{{ item.reminderLine }}</span></td>
<td><span class="budget-threshold alert">{{ item.alertLine }}</span></td>
<td><span class="budget-threshold risk">{{ item.riskLine }}</span></td>
<td data-label="费用类型"><strong>{{ item.name }}</strong></td>
<td data-label="预算金额">{{ item.amountLabel }}</td>
<td data-label="已发生">{{ item.usedLabel }}</td>
<td data-label="已占用">{{ item.occupiedLabel }}</td>
<td data-label="剩余">{{ item.availableLabel }}</td>
<td data-label="使用率">{{ item.usageRateLabel }}</td>
<td data-label="提醒"><span class="budget-threshold reminder">{{ item.reminderLine }}</span></td>
<td data-label="告警"><span class="budget-threshold alert">{{ item.alertLine }}</span></td>
<td data-label="风险"><span class="budget-threshold risk">{{ item.riskLine }}</span></td>
</tr>
</tbody>
</table>

View File

@@ -196,20 +196,20 @@
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
<td>
<td data-label="单号">
<span v-if="row.isNewDocument" class="new-document-badge">NEW</span>
<strong class="doc-id">{{ row.documentNo }}</strong>
</td>
<td>{{ row.createdAtDisplay }}</td>
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
<td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
<td>{{ row.initiatorName }}</td>
<td>{{ row.reason }}</td>
<td>{{ row.amountDisplay }}</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td>{{ row.updatedAtDisplay }}</td>
<td data-label="创建时间">{{ row.createdAtDisplay }}</td>
<td v-if="showStayTimeColumn" data-label="停留时间">{{ row.stayTimeDisplay }}</td>
<td data-label="单据类型"><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
<td data-label="费用场景"><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
<td data-label="发起人">{{ row.initiatorName }}</td>
<td data-label="事项">{{ row.reason }}</td>
<td data-label="金额">{{ row.amountDisplay }}</td>
<td data-label="当前环节">{{ row.node }}</td>
<td data-label="状态"><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td data-label="更新时间">{{ row.updatedAtDisplay }}</td>
</tr>
</tbody>
</table>
@@ -587,7 +587,9 @@ function buildDocumentRow(request, options = {}) {
archived,
createdAtDisplay: formatDocumentListTime(createdAtSource),
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
isNewDocument: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
isNewDocument: archived
? false
: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource)
}

View File

@@ -621,7 +621,7 @@
:class="{ spotlight: employee.spotlight }"
@click="openEmployeeDetail(employee)"
>
<td>
<td data-label="员工">
<div class="employee-cell">
<span class="employee-avatar">{{ employee.avatar }}</span>
<div>
@@ -630,11 +630,11 @@
</div>
</div>
</td>
<td>{{ employee.employeeNo }}</td>
<td>{{ employee.department }}</td>
<td>{{ employee.position }}</td>
<td><span class="level-pill">{{ employee.grade }}</span></td>
<td>
<td data-label="工号">{{ employee.employeeNo }}</td>
<td data-label="部门">{{ employee.department }}</td>
<td data-label="岗位">{{ employee.position }}</td>
<td data-label="职级"><span class="level-pill">{{ employee.grade }}</span></td>
<td data-label="系统角色">
<div class="role-stack">
<span
v-for="role in employee.roles.slice(0, 2)"
@@ -648,10 +648,10 @@
</span>
</div>
</td>
<td>
<td data-label="状态">
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
</td>
<td class="cell-updated">{{ employee.updatedAt }}</td>
<td class="cell-updated" data-label="最近更新">{{ employee.updatedAt }}</td>
</tr>
</tbody>
</table>

View File

@@ -28,6 +28,7 @@ const {
} = useSystemState()
const LOGIN_ENTRY_ROUTE_DELAY_MS = 140
const LOGIN_BRAND_NAME = '易财费控'
function waitForLoginEntryReady() {
return new Promise((resolve) => {
@@ -35,8 +36,6 @@ function waitForLoginEntryReady() {
})
}
const LOGIN_BRAND_NAME = '易财费控'
async function submitLogin(credentials) {
const passed = await handleLogin(credentials)

View File

@@ -1,150 +1,152 @@
<template>
<main class="login-page">
<header class="page-brand">
<LogoMark />
<strong>{{ displayCompanyName }}</strong>
</header>
<section class="hero">
<p class="eyebrow-text">Smart Expense Operations</p>
<h1>企业报销智能运营台</h1>
<p class="hero-lead">让报销审批更智能更高效</p>
<p class="hero-sub">智能审单 · 自动化审批 · 风险预警 · SLA 监控 · 数据驱动决策</p>
<div class="hero-stage" aria-hidden="true">
<span class="flow-line flow-a"></span>
<span class="flow-line flow-b"></span>
<span class="flow-line flow-c"></span>
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>¥ 61,600</strong>
<small>较昨日 <b class="up">+8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
<div class="document-card">
<span>报销单</span>
<i></i><i></i><i></i>
<b class="doc-check"><i class="mdi mdi-check"></i></b>
</div>
<img class="shield-art" src="../assets/security-shield.png" alt="" />
<div class="round-badge ai">AI</div>
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger">+16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up">+6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up">+3.1%</b></small>
</div>
<section class="login-visual" aria-label="智能费用管理运营能力">
<div class="visual-brand">
<LogoMark />
<strong>{{ displayCompanyName }}</strong>
</div>
<div class="feature-strip" aria-label="核心能力">
<div class="visual-copy">
<p>智能费用管理</p>
<h1>让企业财务更高效更合规更可控</h1>
<span>以智能化流程驱动费用全生命周期管理助力企业降本增效稳健前行</span>
</div>
<div class="visual-feature-list" aria-label="核心能力">
<article v-for="item in features" :key="item.title">
<span :class="item.tone"><i :class="item.icon"></i></span>
<span class="visual-feature-icon">
<ElIcon><component :is="item.icon" /></ElIcon>
</span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.desc }}</p>
</div>
</article>
</div>
<img class="visual-main-asset" :src="loginMainVisualImage" alt="" aria-hidden="true" />
<img class="visual-chart-asset" :src="loginChartPanelsImage" alt="" aria-hidden="true" />
<footer class="visual-footer">
<span>© 2024 智能费用管理平台</span>
<i></i>
<span>服务热线400-888-8888</span>
</footer>
</section>
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>{{ displayCompanyName }}</strong>
</div>
<header class="card-head">
<h2>欢迎登录</h2>
<p>使用员工邮箱或管理员账号进入系统</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
</label>
<label class="field">
<span class="sr-only">密码</span>
<i class="mdi mdi-lock-outline"></i>
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入登录密码"
autocomplete="current-password"
required
/>
<button
class="field-icon-btn"
type="button"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
</button>
</label>
<label class="field">
<span class="sr-only">企业或租户</span>
<i class="mdi mdi-office-building"></i>
<select v-model="tenant" class="tenant-select" aria-label="请选择企业或租户">
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
</select>
<span class="field-select-chevron" aria-hidden="true">
<i class="mdi mdi-chevron-down"></i>
</span>
</label>
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住账号</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
<section class="login-panel" aria-label="登录表单">
<div class="login-card">
<div class="card-brand">
<LogoMark />
<strong>{{ displayCompanyName }}</strong>
</div>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
<header class="card-head">
<h2>欢迎登录</h2>
<p>智能费用管理平台</p>
</header>
<button class="submit-btn" type="submit" :disabled="submitting">
{{ submitting ? '登录中...' : '登录' }}
</button>
<form class="login-form" @submit.prevent="submitLogin">
<label class="form-field">
<span class="sr-only">账号</span>
<ElInput
v-model="username"
class="login-input"
:prefix-icon="User"
autocomplete="username"
clearable
placeholder="请输入账号"
/>
</label>
<div class="divider"><span></span></div>
<label class="form-field">
<span class="sr-only">密码</span>
<ElInput
v-model="password"
class="login-input"
:prefix-icon="Lock"
autocomplete="current-password"
placeholder="请输入密码"
show-password
type="password"
/>
</label>
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
</form>
<label class="form-field">
<span class="sr-only">所属企业</span>
<ElSelect
v-model="tenant"
class="login-select"
popper-class="login-tenant-popper"
placeholder="请选择所属企业"
:suffix-icon="OfficeBuilding"
>
<ElOption
v-for="option in tenantOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</label>
<footer class="security-note">
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
</footer>
<div class="form-meta">
<ElCheckbox v-model="remember" class="login-checkbox">记住账号</ElCheckbox>
<button type="button" class="link-button" @click="emit('recover-password')">忘记密码?</button>
</div>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
<ElButton
class="login-submit"
type="primary"
native-type="submit"
:loading="submitting"
:disabled="submitting"
>
登录
</ElButton>
<ElButton
class="login-sso"
:icon="Grid"
:disabled="submitting"
@click="emit('sso-login')"
>
SSO 单点登录
</ElButton>
</form>
<footer class="security-note">
登录即表示您已阅读并同意
<button type="button">用户协议</button>
<button type="button">隐私政策</button>
</footer>
</div>
</section>
</main>
</template>
<script setup>
import { computed } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElCheckbox } from 'element-plus/es/components/checkbox/index.mjs'
import { ElIcon } from 'element-plus/es/components/icon/index.mjs'
import { ElInput } from 'element-plus/es/components/input/index.mjs'
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
import {
Connection,
DataAnalysis,
DocumentChecked,
Grid,
Lock,
OfficeBuilding,
User
} from '@element-plus/icons-vue'
import loginChartPanelsImage from '../assets/login-reference-chart-panels.png'
import loginMainVisualImage from '../assets/login-reference-main-visual.png'
import { useLoginView } from '../composables/useLoginView.js'
const props = defineProps({
@@ -166,7 +168,32 @@ const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const displayCompanyName = computed(() => props.companyName || '易财费控')
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
const {
features,
LogoMark,
password,
remember,
tenant,
tenantOptions,
username
} = useLoginView()
const featureIconMap = {
recognition: DocumentChecked,
workflow: Connection,
insight: DataAnalysis
}
features.forEach((item) => {
item.icon = featureIconMap[item.iconKey] || DocumentChecked
})
function submitLogin() {
emit('login', {
username: username.value,
password: password.value
})
}
</script>
<style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -94,25 +94,25 @@
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
<td>
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td>
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td>
<td data-label="文件名称">
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td data-label="标签">
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td data-label="上传时间">{{ doc.time }}</td>
<td data-label="版本">{{ doc.version }}</td>
<td data-label="状态">
<div class="state-cell">
<span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span>
</div>
</td>
<td class="ingest-time-cell">{{ doc.ingestTime || '—' }}</td>
<td>{{ doc.owner }}</td>
<td>
<td class="ingest-time-cell" data-label="归纳时间">{{ doc.ingestTime || '—' }}</td>
<td data-label="上传人">{{ doc.owner }}</td>
<td data-label="操作">
<div class="row-actions" @click.stop>
<button
class="more-btn"

View File

@@ -92,21 +92,21 @@
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="openDetail(row)">
<td>
<td data-label="票据文件">
<strong class="doc-id">{{ row.file_name }}</strong>
<small>{{ row.summary || '暂无摘要' }}</small>
</td>
<td><span class="doc-kind-tag reimbursement">{{ row.document_type_label }}</span></td>
<td><span class="type-tag neutral">{{ row.scene_label }}</span></td>
<td>{{ row.amount || '待补充' }}</td>
<td>{{ row.document_date || '待补充' }}</td>
<td>{{ formatScore(row.avg_score) }}</td>
<td v-if="showStatusColumn">
<td data-label="识别类型"><span class="doc-kind-tag reimbursement">{{ row.document_type_label }}</span></td>
<td data-label="费用场景"><span class="type-tag neutral">{{ row.scene_label }}</span></td>
<td data-label="金额">{{ row.amount || '待补充' }}</td>
<td data-label="票据日期">{{ row.document_date || '待补充' }}</td>
<td data-label="置信度">{{ formatScore(row.avg_score) }}</td>
<td v-if="showStatusColumn" data-label="关联状态">
<span class="status-tag" :class="row.status === 'linked' ? 'completed' : 'warning'">
{{ row.status_label }}
</span>
</td>
<td>{{ formatDateTime(row.uploaded_at) }}</td>
<td data-label="上传时间">{{ formatDateTime(row.uploaded_at) }}</td>
</tr>
</tbody>
</table>

View File

@@ -162,7 +162,12 @@
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
</div>
<div v-if="hasLeaderApprovalEvents" class="application-leader-opinion-timeline" aria-label="领导批复事件流">
<div
v-if="hasLeaderApprovalEvents"
class="application-leader-opinion-timeline"
:class="{ 'is-single': hasSingleLeaderApprovalEvent }"
aria-label="领导批复事件流"
>
<article
v-for="event in leaderApprovalEvents"
:key="event.id"
@@ -414,7 +419,7 @@
<div class="validation-head">
<div>
<h3>{{ aiAdviceTitle }}</h3>
<p>{{ aiAdviceHint }}</p>
<p v-if="aiAdviceHint">{{ aiAdviceHint }}</p>
</div>
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div>
@@ -426,7 +431,7 @@
:class="['validation-section', `validation-section--${section.kind}`]"
>
<h4 class="validation-section-title">{{ section.title }}</h4>
<ul v-if="section.kind === 'completion'" class="validation-list">
<ul v-if="section.kind !== 'risk'" class="validation-list">
<li v-for="item in section.items" :key="item">{{ item }}</li>
</ul>
<div v-else class="risk-advice-list">
@@ -451,10 +456,6 @@
</section>
</div>
</article>
<RiskObservationEvidenceCard
v-if="request.claimId"
:claim-id="request.claimId"
/>
<StageRiskAdviceCard
v-if="showStageRiskAdvice"
:request="request"
@@ -687,7 +688,7 @@
badge="重大风险"
badge-tone="danger"
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
description="如仍需进入下一步,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
description="如仍需提交审批,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
cancel-text="返回整改"
confirm-text="保存原因并继续"
busy-text="保存中..."

View File

@@ -120,7 +120,9 @@ export default {
status: '全部'
})
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
const canEditBudget = computed(() =>
canEditBudgetCenter(props.currentUser) || isBudgetMonitorUser(props.currentUser)
)
const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser))
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
const isDepartmentBudgetMonitor = computed(
@@ -145,7 +147,10 @@ export default {
})
)
const budgetScopeTabs = computed(() => buildBudgetScopeTabs(budgetRowsByScope.value))
const budgetScopeTabs = computed(() =>
buildBudgetScopeTabs(budgetRowsByScope.value)
.filter((tab) => canAuditBudgetDrafts.value || tab.value !== BUDGET_SCOPE_REVIEW)
)
const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || [])
const activeScopeLabel = computed(
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
@@ -224,14 +229,59 @@ export default {
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
function openBudgetAssistant(prompt = '') {
function buildBudgetAssistantContext(row, mode = 'edit') {
if (!row) return null
return {
mode,
budgetNo: row.budgetNo,
departmentCode: row.departmentCode,
departmentName: row.departmentName,
costCenter: row.costCenter,
periodLabel: row.periodLabel,
periodType: row.periodType,
budgetYear: row.budgetYear,
budgetQuarter: row.budgetQuarter,
version: row.version,
compiler: row.compiler || row.owner,
reviewer: row.reviewer,
submittedAt: row.submittedAt,
requestedAmount: row.requestedAmount || row.quarterAmount,
previousAmount: row.quarterAmount,
categoryRows: Array.isArray(row.categoryRows)
? row.categoryRows.map((item) => ({ ...item }))
: []
}
}
function resolveEditableBudgetRow() {
const allRows = budgetRowsByScope.value[BUDGET_SCOPE_ALL] || []
if (isDepartmentBudgetMonitor.value) {
return allRows.find((row) => (
row.scope === BUDGET_SCOPE_ALL &&
(
(currentUserCostCenter.value && row.costCenter === currentUserCostCenter.value) ||
(currentUserDepartmentName.value && row.departmentName === currentUserDepartmentName.value)
)
)) || allRows[0] || null
}
return allRows.find((row) => row.scope === BUDGET_SCOPE_ALL) || allRows[0] || null
}
function openBudgetAssistant(prompt = '', budgetContext = null) {
if (!canEditBudget.value) return
const context = budgetContext || buildBudgetAssistantContext(resolveEditableBudgetRow(), 'edit')
emit('openAssistant', {
source: 'budget',
sessionType: 'budget',
prompt,
prompt: prompt || (
context?.departmentName
? `编辑${context.departmentName}${context.periodLabel || ''}预算`
: '编辑本部门预算'
),
files: [],
conversation: null
conversation: null,
budgetContext: context
})
}
@@ -242,7 +292,8 @@ export default {
}
openBudgetAssistant(
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`,
buildBudgetAssistantContext(row, 'review')
)
}
@@ -332,6 +383,12 @@ export default {
}
)
watch(canAuditBudgetDrafts, (allowed) => {
if (!allowed && activeBudgetScope.value === BUDGET_SCOPE_REVIEW) {
activeBudgetScope.value = BUDGET_SCOPE_ALL
}
}, { immediate: true })
watch(
[
budgetPageSize,

View File

@@ -532,6 +532,10 @@ export default {
type: Object,
default: null
},
initialBudgetContext: {
type: Object,
default: null
},
initialSessionType: {
type: String,
default: ''
@@ -1109,6 +1113,7 @@ export default {
submitting,
syncComposerFilesToDraft,
emitOperationCompleted,
emitDraftSaved: (payload) => emit('draft-saved', payload),
emitRequestUpdated: (payload) => emit('request-updated', payload),
toast
})
@@ -1881,6 +1886,29 @@ export default {
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
}
function shouldShowDraftSavedCard(message) {
const draftPayload = message?.draftPayload || null
return Boolean(
draftPayload
&& (
String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|| String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|| String(draftPayload.title || '').trim()
|| String(draftPayload.body || '').trim()
)
)
}
function resolveReimbursementDraftClaimNo(draftPayload) {
return String(
draftPayload?.claim_no
|| draftPayload?.claimNo
|| draftPayload?.claim_id
|| draftPayload?.claimId
|| ''
).trim() || '待生成'
}
function updateMessageOperationFeedback(message, patch = {}) {
if (!message?.id) {
return
@@ -1957,7 +1985,7 @@ export default {
const draftPayload = message?.draftPayload || {}
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
if (!claimId) {
toast('暂未获取到申请单据 ID稍后可在单据中心查看。')
toast('暂未获取到单据 ID稍后可在单据中心查看。')
return
}
await router.push({
@@ -2403,6 +2431,8 @@ export default {
isApplicationDraftPayload,
resolveApplicationDraftStatusLabel,
buildApplicationDraftSummaryItems,
shouldShowDraftSavedCard,
resolveReimbursementDraftClaimNo,
openApplicationDraftDetail,
isOperationFeedbackVisible,
dismissOperationFeedbackForMessage,
@@ -2519,7 +2549,7 @@ export default {
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, shouldShowDraftSavedCard, resolveReimbursementDraftClaimNo, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -8,7 +8,6 @@ import TravelRequestApprovalDialog from '../../components/travel/TravelRequestAp
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
approveExpenseClaim,
@@ -16,9 +15,9 @@ import {
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchEmployeeLatestProfile,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
preReviewExpenseClaim,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
@@ -33,7 +32,8 @@ import {
canReturnExpenseClaims,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isFinanceUser
isFinanceUser,
isPlatformAdminUser
} from '../../utils/accessControl.js'
import {
buildRiskViewerContext,
@@ -67,7 +67,6 @@ import {
buildExpenseItemViewModel,
buildFallbackExpenseItems,
buildFallbackProgressSteps,
buildOptionalTravelReceiptRiskCards,
formatCurrency,
isPlaceholderValue,
isApplicationDocumentRequest,
@@ -84,16 +83,15 @@ import {
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
import {
buildAiPreReviewSnapshot,
findLatestAiPreReviewEvent,
isAiPreReviewFlag,
isAiPreReviewPassed,
resolveAiPreReviewToast,
resolveSubmitActionIcon,
resolveSubmitActionLabel,
resolveSubmitConfirmDescription,
resolveSubmitConfirmText
} from './travelRequestDetailPreReviewModel.js'
} from './travelRequestDetailSubmitModel.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
} from './travelRequestDetailAdviceModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
/*
@@ -229,50 +227,6 @@ function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelM
|| source?.created_at
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
requestModel?.typeCode === 'travel' ||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
if (!isTravelContext) {
return []
}
const hasUploadedType = (itemType) =>
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
const cards = []
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
})
}
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
})
}
return cards
}
function buildExpenseDraftIssues(item) {
const issues = []
if (item.isSystemGenerated) {
@@ -394,7 +348,6 @@ export default {
ConfirmDialog,
EnterpriseSelect,
StageRiskAdviceCard,
RiskObservationEvidenceCard,
TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis,
TravelRequestDeleteDialog,
@@ -426,8 +379,11 @@ export default {
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const aiPreReviewSnapshot = ref(null)
const riskFlagPreviewSnapshot = ref(null)
const employeeRiskProfile = ref(null)
const employeeRiskProfileLoading = ref(false)
const employeeRiskProfileError = ref('')
let employeeRiskProfileLoadSeq = 0
const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
@@ -507,6 +463,9 @@ export default {
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
@@ -612,6 +571,7 @@ export default {
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1)
const leaderApprovalReadonlyMeta = computed(() => {
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
@@ -682,7 +642,12 @@ export default {
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
: `${request.value.id} 已审批通过,流转至财务审批。`
})
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteActionLabel = computed(() => {
if (isApplicationDocument.value) {
return '删除申请'
}
return isDraftRequest.value ? '删除草稿' : '删除单据'
})
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
@@ -726,7 +691,6 @@ export default {
Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key]
})
aiPreReviewSnapshot.value = null
closeAttachmentPreview()
}
pendingUploadExpenseId.value = ''
@@ -923,15 +887,6 @@ export default {
) {
requestFlags = previewSnapshot.riskFlags
}
const snapshot = aiPreReviewSnapshot.value
if (
snapshot
&& snapshot.claimId === request.value?.claimId
&& Array.isArray(snapshot.riskFlags)
&& !requestFlags.some(isAiPreReviewFlag)
) {
return snapshot.riskFlags
}
return requestFlags
}
@@ -1093,10 +1048,6 @@ export default {
return summary ? `重大风险警示:${summary}` : '重大风险警示'
}
function applyAiPreReviewPayload(payload) {
aiPreReviewSnapshot.value = buildAiPreReviewSnapshot(payload, request.value.claimId)
}
function applyClaimRiskFlagsPayload(payload) {
const flags = Array.isArray(payload?.claim_risk_flags)
? payload.claim_risk_flags
@@ -1112,11 +1063,69 @@ export default {
}
}
const requiresAiPreReview = computed(() => isEditableRequest.value && !isApplicationDocument.value)
const aiPreReviewEvent = computed(() => findLatestAiPreReviewEvent(resolveClaimRiskFlags()))
const hasAiPreReviewResult = computed(() => !requiresAiPreReview.value || Boolean(aiPreReviewEvent.value))
const aiPreReviewPassed = computed(() =>
isAiPreReviewPassed(aiPreReviewEvent.value, requiresAiPreReview.value)
function resolveProfileLookupId() {
return String(
request.value?.profileEmployeeId
|| request.value?.employeeId
|| request.value?.employee_id
|| request.value?.profileName
|| ''
).trim()
}
function resolveProfileExpenseScope() {
const typeCode = String(request.value?.typeCode || '').trim()
return typeCode && !typeCode.endsWith('_application') ? typeCode : 'overall'
}
async function loadEmployeeRiskProfile() {
const employeeId = resolveProfileLookupId()
if (!employeeId || isApplicationDocument.value) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = ''
employeeRiskProfileLoading.value = false
return
}
const sequence = ++employeeRiskProfileLoadSeq
employeeRiskProfileLoading.value = true
employeeRiskProfileError.value = ''
try {
const payload = await fetchEmployeeLatestProfile(employeeId, {
scene: 'approval',
claim_id: request.value?.claimId || '',
window_days: 90,
expense_type_scope: resolveProfileExpenseScope()
})
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = payload || null
}
} catch (error) {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = error?.message || '用户画像读取失败'
}
} finally {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfileLoading.value = false
}
}
}
watch(
() => [
request.value?.claimId,
request.value?.profileEmployeeId,
request.value?.employeeId,
request.value?.employee_id,
request.value?.profileName,
request.value?.typeCode,
isApplicationDocument.value
].join('|'),
() => {
void loadEmployeeRiskProfile()
},
{ immediate: true }
)
const aiAdvice = computed(() => {
@@ -1143,19 +1152,22 @@ export default {
}),
currentBusinessStage
)
const optionalRiskCards = filterRiskCardsByBusinessStage(
buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value),
currentBusinessStage
)
const materialPrompts = currentBusinessStage === 'reimbursement'
? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value)
: []
const profileAdviceItems = currentBusinessStage === 'reimbursement'
? buildEmployeeProfileAdviceItems(employeeRiskProfile.value)
: []
const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards,
...optionalRiskCards
...directRiskCards
]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
return buildAiAdviceViewModel({
completionItems,
materialPrompts,
profileAdviceItems,
riskCards
})
})
@@ -1164,12 +1176,17 @@ export default {
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
)
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
const showCompactSafeAdvice = computed(() =>
isEditableRequest.value
&& !isApplicationDocument.value
&& !draftBlockingIssues.value.length
)
const showAiAdvicePanel = computed(() => (
(
isEditableRequest.value
&& (
(requiresAiPreReview.value && hasAiPreReviewResult.value)
|| hasAdviceSections.value
hasAdviceSections.value
|| showCompactSafeAdvice.value
)
)
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
@@ -1188,24 +1205,22 @@ export default {
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
: isEditableRequest.value
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : 'AI预审已完成请按风险提示补充原因或进入下一步。')
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
: '展示系统已识别的风险点,便于审批和后续整改。'
))
const submitActionLabel = computed(() => {
return resolveSubmitActionLabel({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value,
submitBusy: submitBusy.value
})
})
const submitActionIcon = computed(() => resolveSubmitActionIcon({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value
isApplicationDocument: isApplicationDocument.value
}))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value,
aiPreReviewPassed: aiPreReviewPassed.value
hasHighRiskWarnings: submitRiskWarnings.value.length > 0
}))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
@@ -1751,21 +1766,6 @@ export default {
}
}
async function runAiPreReview() {
submitBusy.value = true
try {
const payload = await preReviewExpenseClaim(request.value.claimId)
applyAiPreReviewPayload(payload)
const event = findLatestAiPreReviewEvent(payload?.risk_flags_json || [])
toast(resolveAiPreReviewToast(event))
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || 'AI预审失败请稍后重试。')
} finally {
submitBusy.value = false
}
}
async function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
@@ -1782,11 +1782,6 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog()
return
@@ -1822,12 +1817,6 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
submitConfirmDialogOpen.value = false
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false
openRiskOverrideDialog()
@@ -1843,10 +1832,10 @@ export default {
toast(
isApplicationDocument.value
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${request.value.id}完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
: `${request.value.id}提交审批${approvalStage ? `,当前节点:${approvalStage}` : ''}`
)
} else if (claimStatus === 'supplement') {
toast(`${request.value.id} AI预审未通过,已转待补充。`)
toast(`${request.value.id} 自动检测未通过,已转待补充。`)
} else {
toast(`${request.value.id} 提交结果已更新。`)
}
@@ -2062,7 +2051,7 @@ export default {
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,

View File

@@ -116,21 +116,105 @@ function resolvePreviousPeriod(year, quarter) {
return { year: year - 1, quarter: 4 }
}
export function shouldUseBudgetCompileReport(rawText, options = {}) {
if (String(options.sessionType || '').trim() !== 'budget') {
return false
function resolveDepartmentNameFromText(rawText) {
const text = String(rawText || '')
const match = text.match(/(市场部|财务部|技术部|人力资源部|生产部|总裁办)/)
return match ? match[1] : ''
}
function normalizeBudgetContext(context) {
return context && typeof context === 'object' ? context : null
}
function resolveContextMode(context) {
return String(context?.mode || '').trim() === 'review' ? 'review' : 'edit'
}
function buildFinanceSuggestion(item, mode) {
if (mode !== 'review') {
return ''
}
if (item.riskTone === 'risk') {
return `${item.name}增幅较高,建议压降到可归控额度,并要求预算管理者补充业务依据。`
}
if (item.riskTone === 'alert') {
return `${item.name}建议结合上一周期实际发生额复核,避免预算冗余。`
}
return `${item.name}预算结构基本合理,建议按提交金额形成预算。`
}
function resolveSuggestedBudgetAmount(row) {
const amount = Number(row.amount || row.budgetAmount || row.recommendedBudget || 0)
const tone = String(row.riskTone || '').trim()
if (tone === 'risk') return Math.round(amount * 0.92)
if (tone === 'alert') return Math.round(amount * 0.96)
return amount
}
function buildItemsFromBudgetContext(context, fallbackItems) {
const rows = Array.isArray(context?.categoryRows) ? context.categoryRows : []
const mode = resolveContextMode(context)
if (!rows.length) return fallbackItems
return rows.map((row, index) => {
const fallback = fallbackItems[index] || PREVIOUS_QUARTER_SPEND[index] || {}
const amount = Number(row.amount || fallback.recommendedBudget || 0)
const used = Number(row.used || 0)
const occupied = Number(row.occupied || 0)
const value = used + occupied
const suggestedBudget = resolveSuggestedBudgetAmount(row)
const item = {
key: row.code || fallback.key || `budget-${index}`,
name: row.name || fallback.name || '预算科目',
value,
previousValue: Number(fallback.previousValue || 0),
recommendedBudget: suggestedBudget,
color: BUDGET_REPORT_COLORS[row.code] || fallback.color || BUDGET_REPORT_COLORS.travel,
drivers: Array.isArray(fallback.drivers) ? fallback.drivers : [],
risk: row.note || `${row.name || '该费用类型'}预算提交金额为 ${compactCurrency(amount)},已发生与已占用合计 ${compactCurrency(value)}`,
suggestion: buildFinanceSuggestion({ ...row, name: row.name || fallback.name, riskTone: row.riskTone }, mode),
amountDisplay: compactCurrency(value),
display: row.usageRateLabel || '0.0%',
share: row.usageRateLabel || '0.0%',
trend: row.usageRateLabel || '0.0%',
trendTone: row.riskTone === 'risk' ? 'risk' : row.riskTone === 'alert' ? 'warn' : 'stable',
recommendedDisplay: compactCurrency(suggestedBudget),
editableBudget: amount,
suggestedBudget,
submittedNote: row.note || '',
financeSuggestion: buildFinanceSuggestion({ ...row, name: row.name || fallback.name, riskTone: row.riskTone }, mode)
}
return item
})
}
export function shouldUseBudgetCompileReport(rawText, options = {}) {
const sessionType = String(options.sessionType || '').trim()
const entrySource = String(options.entrySource || '').trim()
const budgetContext = normalizeBudgetContext(options.budgetContext)
const text = normalizeBudgetText(rawText)
const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText)
const hasBudgetKeyword = /(预算|budget)/.test(text)
const hasCompileKeyword = /(编制|制定|测算|生成|规划|预算一下|编辑|修改|调整|compile|create|plan|edit)/.test(text)
const hasReviewKeyword = /(审核|复核|审预算|形成预算|回退预算|review|audit)/.test(text)
const isBudgetContext = sessionType === 'budget' || entrySource === 'budget'
const isWholeBudgetCompileIntent = hasBudgetKeyword && hasCompileKeyword && hasTargetPeriod
const isBudgetContextPeriodIntent = isBudgetContext && hasBudgetKeyword && (hasTargetPeriod || hasReviewKeyword)
const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword
return Boolean(
text &&
/(预算|budget)/.test(text) &&
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
hasTargetPeriod
budgetContext ||
(
text &&
(isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent)
)
)
}
export function buildBudgetCompileReport(rawText, user = {}) {
export function buildBudgetCompileReport(rawText, user = {}, budgetContext = null) {
const context = normalizeBudgetContext(budgetContext)
const contextMode = resolveContextMode(context)
const isReviewMode = contextMode === 'review'
const targetYear = parseYear(rawText)
const parsedQuarter = parseQuarter(rawText)
const isAnnualBudget = !parsedQuarter
@@ -142,12 +226,34 @@ export function buildBudgetCompileReport(rawText, user = {}) {
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0)
const totalBudget = 1320000 * periodMultiplier
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0)
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
const departmentName = String(
context?.departmentName ||
resolveDepartmentNameFromText(rawText) ||
user.departmentName ||
user.department ||
user.department_name ||
''
).trim() || '当前部门'
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
const simulatedItems = PREVIOUS_QUARTER_SPEND.map((item) => {
const value = item.value * periodMultiplier
const previousValue = item.previousValue * periodMultiplier
const recommendedBudget = item.recommendedBudget * periodMultiplier
const risk = isAnnualBudget
? item.risk
.replace(/Q2/g, `${previous.year}年度`)
.replace(/Q3/g, `${targetYear}年度`)
.replace(/季度/g, '年度')
: item.risk
const suggestion = isAnnualBudget
? item.suggestion
.replace(/Q3/g, `${targetYear}年度`)
.replace(/季度/g, '年度')
.replace(/52-56 万/g, '208-224 万')
.replace(/30-32 万/g, '120-128 万')
.replace(/19-20 万/g, '76-80 万')
.replace(/10-11 万/g, '40-44 万')
: item.suggestion
const trendValue = item.previousValue
? ((value - previousValue) / previousValue) * 100
: 0
@@ -166,9 +272,15 @@ export function buildBudgetCompileReport(rawText, user = {}) {
reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70,
alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80,
riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90,
editNote: item.suggestion
risk,
suggestion,
editNote: suggestion
}
})
const items = buildItemsFromBudgetContext(context, simulatedItems)
const reportSpend = isReviewMode
? items.reduce((sum, item) => sum + Number(item.value || 0), 0)
: totalSpend
const topItem = [...items].sort((a, b) => b.value - a.value)[0]
const growthItem = [...items].sort((a, b) => {
@@ -177,49 +289,69 @@ export function buildBudgetCompileReport(rawText, user = {}) {
return bGrowth - aGrowth
})[0]
const submittedBudgetTotal = items.reduce((sum, item) => sum + Number(item.editableBudget || item.recommendedBudget || 0), 0)
const financeSuggestedTotal = items.reduce((sum, item) => sum + Number(item.suggestedBudget || item.recommendedBudget || 0), 0)
return {
type: 'budget_compile_analysis',
title: isAnnualBudget
? `${targetYear}年度预算编制前置分析报告`
: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: isAnnualBudget
? `基于${previous.year}年度预算执行模拟数据`
: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`,
mode: contextMode,
title: isReviewMode
? `${departmentName}${context?.periodLabel || ''}预算审核分析报告`
: isAnnualBudget
? `${targetYear}年度预算编制前置分析报告`
: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: isReviewMode
? `${context?.budgetNo || '部门提交预算'} / ${context?.version || '待审核版本'}`
: isAnnualBudget
? `基于${previous.year}年度预算执行模拟数据`
: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`,
departmentName,
targetPeriod: isAnnualBudget ? `${targetYear}年度` : `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`,
targetPeriod: context?.periodLabel || (isAnnualBudget ? `${targetYear}年度` : `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`),
basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
periodType: isAnnualBudget ? '年度预算' : '季度预算',
centerValue: compactCurrency(totalSpend),
centerValue: compactCurrency(reportSpend),
centerLabel: isAnnualBudget ? '去年开销' : '上季度开销',
summary: {
totalBudget: compactCurrency(totalBudget),
totalSpend: compactCurrency(totalSpend),
usageRate: percent(totalSpend, totalBudget),
recommendedTotal: compactCurrency(recommendedTotal)
totalBudget: compactCurrency(isReviewMode ? submittedBudgetTotal : totalBudget),
totalSpend: compactCurrency(reportSpend),
usageRate: percent(reportSpend, isReviewMode ? submittedBudgetTotal : totalBudget),
recommendedTotal: compactCurrency(isReviewMode ? financeSuggestedTotal : recommendedTotal)
},
macroInsights: [
`${isAnnualBudget ? `${previous.year}年度` : `${previous.year}${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}预算编制的第一优先级`,
`${growthItem.name}环比增长 ${growthItem.trend}需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料`
isReviewMode
? `${departmentName}本次提交预算 ${compactCurrency(submittedBudgetTotal)}AI 建议可归控预算 ${compactCurrency(financeSuggestedTotal)},请高级财务人员确认是否需要回退调整`
: `${isAnnualBudget ? `${previous.year}年度` : `${previous.year}${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)}预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isReviewMode ? '审核重点' : `${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}预算编制的第一优先级`}`,
isReviewMode
? `${growthItem.name}需要重点核对预算说明、业务依据和可归控空间;如果建议预算低于提交预算,应写明回退理由。`
: `${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
],
items,
editableDraft: {
status: 'editing',
mode: contextMode,
departmentName,
rows: items.map((item) => ({
key: item.key,
name: item.name,
departmentName,
budgetAmount: item.editableBudget,
reminderThreshold: item.reminderThreshold,
alertThreshold: item.alertThreshold,
riskThreshold: item.riskThreshold,
note: item.editNote
suggestedBudget: item.suggestedBudget || item.recommendedBudget || item.editableBudget,
submittedNote: item.submittedNote || item.editNote,
financeSuggestion: item.financeSuggestion || ''
}))
},
recommendations: [
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
],
recommendations: isReviewMode
? [
'审核时先看预算管理者提交说明是否覆盖业务增长、已占用事项和可归控边界。',
'建议预算低于提交预算时,应在“建议”列写明压降原因,并回退预算给预算管理者再次编辑。',
'如果建议预算与提交预算一致且说明充分,可以直接形成正式预算。'
]
: [
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
],
generatedAt: '模拟数据 · 用于 Demo 预览'
}
}
@@ -243,20 +375,31 @@ export async function handleBudgetCompileReportSubmit(runtime) {
rawText,
replaceMessage,
resetFlowRun,
refreshCurrentUserFromBackend,
budgetContext,
scrollToBottom,
startFlowStep,
submitting,
userText
} = runtime
const analysisStartedAt = Date.now()
const context = normalizeBudgetContext(budgetContext)
const isReviewRequest = resolveContextMode(context) === 'review'
const isAnnualRequest = hasExplicitYear(rawText) && !parseQuarter(rawText)
const basePeriodLabel = isReviewRequest ? '部门提交预算分析' : isAnnualRequest ? '去年预算开销分析' : '上季度预算开销分析'
const recommendationLabel = isReviewRequest ? '高级财务审核建议生成' : isAnnualRequest ? '年度预算编制建议生成' : '预算编制建议生成'
resetFlowRun()
startFlowStep('budget-prior-quarter-analysis', {
title: '上季度预算开销分析',
title: basePeriodLabel,
tool: 'budget.analysis.previous_quarter',
detail: '正在汇总上季度费用占比、增长点和下一季度编制建议...'
detail: isReviewRequest
? '正在读取部门提交预算表,分析费用结构、历史消耗和可归控空间...'
: isAnnualRequest
? '正在汇总去年费用占比、增长点和年度预算编制建议...'
: '正在汇总上季度费用占比、增长点和下一季度编制建议...'
})
startFlowStep('budget-compile-guidance', {
title: '预算编制建议生成',
title: recommendationLabel,
tool: 'budget.compile.recommendation',
detail: '正在生成预算编制前置分析报告...'
})
@@ -265,7 +408,11 @@ export async function handleBudgetCompileReportSubmit(runtime) {
}
const pendingMessage = createMessage(
'assistant',
'我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。',
isReviewRequest
? '我先加载部门提交的预算表,结合费用结构和预算说明生成高级财务审核建议。'
: isAnnualRequest
? '我先不直接进入预算表单,先执行去年预算开销结构分析,再给您一版年度预算编制建议。'
: '我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。',
[],
{ meta: ['预算分析中'] }
)
@@ -286,20 +433,36 @@ export async function handleBudgetCompileReportSubmit(runtime) {
try {
await new Promise((resolve) => setTimeout(resolve, 360))
const budgetReport = buildBudgetCompileReport(rawText, currentUser.value || {})
let reportUser = currentUser.value || {}
const hasUserDepartment = String(
reportUser.departmentName || reportUser.department || reportUser.department_name || ''
).trim()
if (!hasUserDepartment && typeof refreshCurrentUserFromBackend === 'function') {
await refreshCurrentUserFromBackend({ silent: true })
reportUser = currentUser.value || reportUser
}
const budgetReport = buildBudgetCompileReport(rawText, reportUser, context)
completeFlowStep(
'budget-prior-quarter-analysis',
'已完成上季度费用占比、增长点和风险点分析',
isReviewRequest
? '已完成部门提交预算、费用结构和风险点分析'
: isAnnualRequest
? '已完成去年费用占比、增长点和风险点分析'
: '已完成上季度费用占比、增长点和风险点分析',
Date.now() - analysisStartedAt
)
completeFlowStep(
'budget-compile-guidance',
'已生成下一季度预算编制建议',
isReviewRequest ? '已生成高级财务审核建议' : isAnnualRequest ? '已生成年度预算编制建议' : '已生成下一季度预算编制建议',
Date.now() - analysisStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
'下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。',
isReviewRequest
? '下面先按部门提交的预算草案做一版审核分析。正式接入预算池后,这里会替换成真实提交记录、历史消耗和归控建议。'
: isAnnualRequest
? '下面先按去年模拟数据做一版年度预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。'
: '下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。',
[],
{
meta: ['预算分析报告', '模拟数据'],

View File

@@ -73,6 +73,34 @@ function normalizeApplicationDate(claim) {
)
}
function normalizeApplicationDateText(value) {
const text = normalizeText(value)
if (!text) {
return ''
}
const matched = text.match(/^(\d{4}-\d{2}-\d{2})/)
return matched?.[1] || text
}
function normalizeApplicationBusinessTime(claim) {
const start = normalizeApplicationDateText(claim?.start_date || claim?.startDate || claim?.begin_date || claim?.beginDate)
const end = normalizeApplicationDateText(claim?.end_date || claim?.endDate || claim?.finish_date || claim?.finishDate)
if (start && end && start !== end) {
return `${start}${end}`
}
return normalizeApplicationDateText(
start
|| claim?.business_time
|| claim?.businessTime
|| claim?.time_range
|| claim?.timeRange
|| claim?.occurred_at
|| claim?.occurredAt
|| claim?.occurred_date
|| claim?.occurredDate
)
}
function toTimestamp(value) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
@@ -216,6 +244,7 @@ export function normalizeRequiredApplicationCandidate(claim) {
location,
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
amount_label: amountText,
business_time: normalizeApplicationBusinessTime(claim),
status,
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
application_date: normalizeApplicationDate(claim)
@@ -247,6 +276,7 @@ export function buildRequiredApplicationActions(applications, actionType) {
const claimNo = normalizeText(application.claim_no) || '未编号申请单'
const description = [
application.status_label,
application.business_time && `时间:${application.business_time}`,
application.location && `地点:${application.location}`,
application.amount_label && `预算:${application.amount_label}`,
application.reason && `事由:${application.reason}`
@@ -265,6 +295,7 @@ export function buildRequiredApplicationActions(applications, actionType) {
application_location: application.location,
application_amount: application.amount,
application_amount_label: application.amount_label,
application_business_time: application.business_time,
application_status: application.status,
application_status_label: application.status_label,
application_date: application.application_date

View File

@@ -160,6 +160,12 @@ export const FLOW_STEP_FALLBACKS = {
runningText: '正在把已确认信息保存为草稿...',
completedText: '草稿已保存'
},
'draft-risk-review': {
title: '草稿风险识别',
tool: 'RuleEngine',
runningText: '正在对草稿执行规则校验...',
completedText: '已完成草稿风险识别'
},
'application-submit-success': {
title: '申请单提交成功',
tool: 'ApplicationSubmit',

View File

@@ -110,6 +110,20 @@ function normalizeValues(values) {
}, {})
}
function hasLinkedApplication(values) {
return Boolean(normalizeText(values?.application_claim_id) || normalizeText(values?.application_claim_no))
}
function buildApplicationSummaryParts(values) {
return [
normalizeText(values?.application_claim_no),
normalizeText(values?.application_reason),
normalizeText(values?.application_business_time),
normalizeText(values?.application_location),
normalizeText(values?.application_amount_label || values?.application_amount)
].filter(Boolean)
}
function normalizeApplicationCandidates(applications) {
if (!Array.isArray(applications)) {
return []
@@ -125,6 +139,7 @@ function normalizeApplicationCandidates(applications) {
location: normalizeText(item.location || item.application_location),
amount: normalizeText(item.amount || item.application_amount),
amount_label: normalizeText(item.amount_label || item.application_amount_label),
business_time: normalizeText(item.business_time || item.application_business_time),
status: normalizeText(item.status || item.application_status),
status_label: normalizeText(item.status_label || item.application_status_label),
application_date: normalizeText(item.application_date)
@@ -238,7 +253,6 @@ export function waitForGuidedApplicationSelection(state, expenseType, applicatio
export function selectGuidedRequiredApplication(state, application = {}) {
const current = normalizeGuidedFlowState(state)
const steps = getGuidedReimbursementSteps(current.expenseType)
return {
...current,
values: normalizeValues({
@@ -249,9 +263,11 @@ export function selectGuidedRequiredApplication(state, application = {}) {
application_location: application.application_location || application.location || '',
application_amount: application.application_amount || application.amount || '',
application_amount_label: application.application_amount_label || application.amount_label || '',
application_status_label: application.application_status_label || application.status_label || ''
application_business_time: application.application_business_time || application.business_time || '',
application_status_label: application.application_status_label || application.status_label || '',
application_date: application.application_date || ''
}),
stepKey: steps[0]?.key || 'summary',
stepKey: 'summary',
pendingInterruptionText: '',
applicationCandidates: []
}
@@ -346,40 +362,41 @@ export function buildGuidedReimbursementSummaryText(state) {
const current = normalizeGuidedFlowState(state)
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销'
const steps = getGuidedReimbursementSteps(current.expenseType)
const linkedApplication = hasLinkedApplication(current.values)
const lines = [
`已完成“${typeLabel}”的引导填写。`,
'',
'请核查下面的关键信息:'
]
if (current.values.application_claim_no) {
const applicationParts = [
current.values.application_claim_no,
current.values.application_reason,
current.values.application_location,
current.values.application_amount_label
].filter(Boolean)
if (linkedApplication) {
const applicationParts = buildApplicationSummaryParts(current.values)
lines.push(`- 关联申请单:${applicationParts.join(' / ')}`)
lines.push('- 报销票据:可先生成草稿,随后在草稿详情中上传对应票据。')
} else {
steps.forEach((step) => {
const value = step.key === 'attachments'
? (current.values.attachment_names?.length
? current.values.attachment_names.join('、')
: current.values.attachments || '稍后上传')
: current.values[step.key]
lines.push(`- ${step.summaryLabel}${value || '待补充'}`)
})
}
steps.forEach((step) => {
const value = step.key === 'attachments'
? (current.values.attachment_names?.length
? current.values.attachment_names.join('、')
: current.values.attachments || '稍后上传')
: current.values[step.key]
lines.push(`- ${step.summaryLabel}${value || '待补充'}`)
})
lines.push('')
lines.push('如果这些信息无误,我可以继续生成右侧报销核对信息;生成核对信息后,再由你决定保存草稿或继续下一步。')
lines.push(
linkedApplication
? '如果关联信息无误,我可以直接生成报销草稿;后续由你在草稿详情中上传和归集票据。'
: '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。'
)
return lines.join('\n')
}
export function buildGuidedReviewConfirmationActions() {
return [{
label: '生成报销核对信息',
description: '进入现有报销核对流程,不会直接保存草稿',
label: '生成报销草稿',
description: '使用当前信息生成草稿,票据可在草稿详情继续上传',
icon: 'mdi mdi-clipboard-check-outline',
action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW
}]
@@ -390,14 +407,23 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
const type = getGuidedExpenseType(current.expenseType)
const values = current.values || {}
const typeLabel = type?.label || '其他费用'
const fieldLines = getGuidedReimbursementSteps(current.expenseType).map((step) => {
const value = step.key === 'attachments'
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
: values[step.key]
return `${step.summaryLabel}${value || '待补充'}`
})
if (values.application_claim_no) {
fieldLines.unshift(`关联申请单:${values.application_claim_no}`)
const linkedApplication = hasLinkedApplication(values)
const applicationReason = values.application_reason || ''
const applicationLocation = values.application_location || ''
const applicationAmount = values.application_amount || values.application_amount_label || ''
const applicationBusinessTime = values.application_business_time || ''
const fieldLines = []
if (linkedApplication) {
const applicationParts = buildApplicationSummaryParts(values)
fieldLines.push(`关联申请单:${applicationParts.join(' / ')}`)
fieldLines.push('报销票据:草稿生成后在详情中上传')
} else {
getGuidedReimbursementSteps(current.expenseType).forEach((step) => {
const value = step.key === 'attachments'
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
: values[step.key]
fieldLines.push(`${step.summaryLabel}${value || '待补充'}`)
})
}
const rawText = [
`报销类型:${typeLabel}`,
@@ -406,31 +432,35 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
const reviewFormValues = {
expense_type: typeLabel,
reimbursement_type: typeLabel,
reason: values.reason || values.customer_name || '',
reason_value: values.reason || '',
reason: values.reason || applicationReason || values.customer_name || '',
reason_value: values.reason || applicationReason || '',
customer_name: values.customer_name || '',
participants: values.participants || '',
location: values.location || '',
business_location: values.location || '',
time_range: values.time_range || '',
business_time: values.time_range || '',
amount: values.amount || '',
location: values.location || applicationLocation || '',
business_location: values.location || applicationLocation || '',
time_range: values.time_range || applicationBusinessTime || '',
business_time: values.time_range || applicationBusinessTime || '',
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
application_claim_id: values.application_claim_id || '',
application_claim_no: values.application_claim_no || '',
application_reason: values.application_reason || '',
application_location: values.application_location || '',
application_amount: values.application_amount || ''
application_amount: values.application_amount || '',
application_amount_label: values.application_amount_label || '',
application_business_time: values.application_business_time || '',
application_date: values.application_date || ''
}
return {
rawText,
userText: '生成报销核对信息',
pendingText: '正在生成右侧报销核对信息...',
userText: '生成报销草稿',
pendingText: '正在生成报销草稿...',
systemGenerated: true,
files,
extraContext: {
draft_claim_id: '',
review_action: 'save_draft',
user_input_text: rawText,
expense_scene_selection: {
expense_type: type?.key || current.expenseType || 'other',

View File

@@ -1306,6 +1306,25 @@ export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
if (savedDraft) {
const issueParts = []
if (riskBriefs.length) {
issueParts.push(`${riskBriefs.length} 条风险/异常提醒`)
}
if (pendingCount || extraMissingCount) {
issueParts.push(`${pendingCount || extraMissingCount} 项待补充信息`)
}
return {
lead: '后续处理:',
tone: riskBriefs.length || pendingCount || extraMissingCount ? 'danger' : 'neutral',
summary: issueParts.length
? `自动检测识别到 ${issueParts.join('、')},请进入详情核对;如还有票据可继续上传。`
: '自动检测暂未发现明确风险;如还有票据可继续上传。',
items: [],
notes: []
}
}
if (pendingCount || extraMissingCount) {
const summarySignature = [
pendingCount || extraMissingCount,

View File

@@ -0,0 +1,119 @@
function normalizeText(value) {
return String(value || '').trim()
}
function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function isPlaceholderValue(value) {
const text = normalizeText(value)
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function isApplicationDocumentRequest(requestModel) {
const documentType = normalizeText(
requestModel?.documentTypeCode
|| requestModel?.document_type_code
|| requestModel?.documentType
|| requestModel?.document_type
).toLowerCase()
const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase()
return documentType === 'application' || claimNo.startsWith('AP-') || claimNo.startsWith('APP-')
}
function isHotelExpenseItem(item) {
const text = [
item?.itemType,
item?.typeCode,
item?.name,
item?.category,
item?.desc,
item?.itemReason
].map((value) => normalizeText(value)).join(' ')
return /hotel_ticket|hotel|住宿|酒店|水单/.test(text)
}
export function buildTravelReceiptMaterialPrompts(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const missingHotelItems = normalizedItems.filter(
(item) => !item?.isSystemGenerated && isHotelExpenseItem(item) && isPlaceholderValue(item.invoiceId)
)
if (!missingHotelItems.length) {
return []
}
return [
`当前包含 ${missingHotelItems.length} 条住宿费用明细,但暂未关联住宿发票或酒店水单。请补充住宿材料,避免后续被退回补件。`
]
}
function profileMetric(profile, key) {
const profiles = Array.isArray(profile?.profiles) ? profile.profiles : []
for (const item of profiles) {
const metrics = item?.metrics && typeof item.metrics === 'object' ? item.metrics : {}
const value = Number(metrics[key])
if (Number.isFinite(value) && value > 0) {
return value
}
}
return 0
}
function profileReviewSuggestionTexts(profile) {
const suggestions = Array.isArray(profile?.review_suggestions)
? profile.review_suggestions
: Array.isArray(profile?.reviewSuggestions)
? profile.reviewSuggestions
: []
return suggestions
.map((item) => normalizeText(item?.message || item?.title || item?.label))
.filter(Boolean)
}
function profileRiskTagTexts(profile) {
const tags = Array.isArray(profile?.profile_tags)
? profile.profile_tags
: Array.isArray(profile?.profileTags)
? profile.profileTags
: []
return tags
.filter((tag) => normalizeText(tag?.polarity) === 'risk')
.map((tag) => normalizeText(tag?.reason || tag?.display_label || tag?.label))
.filter(Boolean)
}
export function buildEmployeeProfileAdviceItems(profile) {
if (!profile || typeof profile !== 'object') {
return []
}
const returnCount = profileMetric(profile, 'return_count')
const missingAttachmentCount = profileMetric(profile, 'missing_attachment_count')
const invoiceMismatchCount = profileMetric(profile, 'invoice_mismatch_count')
const missingContextCount = profileMetric(profile, 'missing_business_context_count')
const items = []
if (returnCount > 0) {
items.push(`历史退单建议:近 90 天存在 ${returnCount} 次退单或退回记录,提交前重点复核退回原因对应的票据、事由和说明,避免重复被退。`)
}
if (missingAttachmentCount > 0 || missingContextCount > 0) {
items.push(`材料完整性建议:历史材料或业务上下文缺失累计 ${missingAttachmentCount + missingContextCount} 项,本次提交前请重点核对附件、事由、地点和补充说明。`)
}
if (invoiceMismatchCount > 0) {
items.push(`票据一致性建议:历史存在 ${invoiceMismatchCount} 次票据不一致记录,本次请重点核对票据日期、城市、金额和费用明细。`)
}
return uniqueTexts([
...items,
...profileReviewSuggestionTexts(profile),
...profileRiskTagTexts(profile)
]).slice(0, 4)
}

View File

@@ -27,6 +27,7 @@ export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
])
export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
export const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set(['ride_ticket', 'travel_allowance'])
export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
@@ -88,6 +89,11 @@ export function isSystemGeneratedExpenseItemSource(source) {
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
}
export function isAttachmentRequiredExpenseItem(source) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
return !isSystemGeneratedExpenseItemSource({ ...source, itemType }) && !OPTIONAL_ATTACHMENT_EXPENSE_TYPES.has(itemType)
}
export function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
@@ -200,12 +206,11 @@ export function buildFallbackProgressSteps(requestModel = {}) {
return [
{ index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication },
{ index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication },
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
{ index: 5, label: '财务审批', time: '待处理' },
{ index: 6, label: '付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
{ index: 8, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
{ index: 3, label: '直属领导审批', time: '待处理' },
{ index: 4, label: '财务审批', time: '待处理' },
{ index: 5, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
{ index: 6, label: '付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
{ index: 7, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
]
}
@@ -476,59 +481,13 @@ export function buildExpenseDraftIssues(item) {
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
issues.push('缺少金额')
}
if (isPlaceholderValue(item.invoiceId)) {
if (isAttachmentRequiredExpenseItem(item) && isPlaceholderValue(item.invoiceId)) {
issues.push('缺少票据标识')
}
return issues
}
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
requestModel?.typeCode === 'travel' ||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
if (!isTravelContext) {
return []
}
const hasUploadedType = (itemType) =>
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
const cards = []
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
})
}
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
})
}
return cards
}
export function buildDraftBlockingIssues(request, expenseItems) {
const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode)

View File

@@ -700,24 +700,30 @@ export function buildClaimSummaryRiskCards(request = {}) {
})]
}
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
export function buildAiAdviceViewModel({
completionItems = [],
materialPrompts = [],
profileAdviceItems = [],
riskCards = []
} = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
const items = [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
]
if (
!normalizedCompletionItems.length
&& !normalizedMaterialPrompts.length
&& !normalizedProfileAdviceItems.length
&& !normalizedRiskCards.length
) {
return {
tone: 'ready',
badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items,
badge: '可提交',
summary: '自动检测未发现票据、金额、行程或历史画像异常,可以提交审批。',
items: [],
riskCards: [],
sections: []
}
@@ -731,6 +737,20 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
items: normalizedCompletionItems
})
}
if (normalizedMaterialPrompts.length) {
sections.push({
kind: 'material',
title: '材料补充提示',
items: normalizedMaterialPrompts
})
}
if (normalizedProfileAdviceItems.length) {
sections.push({
kind: 'profile',
title: '历史操作建议',
items: normalizedProfileAdviceItems
})
}
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
@@ -742,10 +762,12 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
badge: hasHighRisk ? '优先整改' : normalizedRiskCards.length ? '待核对' : '建议关注',
summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
: '建议先补齐必填信息,完成后即可提交审批。',
? `自动检测发现 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
: normalizedMaterialPrompts.length
? `自动检测发现 ${normalizedMaterialPrompts.length} 条材料补充提示,不作为风险计数。`
: '结合历史操作记录生成提交建议,请按提示核对后提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections

View File

@@ -1,74 +0,0 @@
export function isAiPreReviewFlag(flag) {
if (!flag || typeof flag !== 'object') {
return false
}
const source = String(flag.source || '').trim()
const eventType = String(flag.event_type || flag.eventType || '').trim()
return source === 'ai_pre_review' || eventType === 'expense_claim_ai_pre_review'
}
export function findLatestAiPreReviewEvent(flags = []) {
return flags
.filter(isAiPreReviewFlag)
.map((flag) => ({
...flag,
eventTime: new Date(flag.created_at || flag.createdAt || 0).getTime()
}))
.sort((left, right) => (left.eventTime || 0) - (right.eventTime || 0))
.pop() || null
}
export function buildAiPreReviewSnapshot(payload, fallbackClaimId = '') {
return {
claimId: String(payload?.id || fallbackClaimId || '').trim(),
riskFlags: Array.isArray(payload?.risk_flags_json) ? payload.risk_flags_json : []
}
}
export function isAiPreReviewPassed(event, requiresAiPreReview) {
if (!requiresAiPreReview) {
return true
}
return Boolean(event?.passed) || String(event?.status || '').trim() === 'passed'
}
export function resolveSubmitActionLabel({
isApplicationDocument,
hasAiPreReviewResult,
submitBusy
}) {
if (isApplicationDocument) {
return submitBusy ? '提交中' : '提交审批'
}
if (!hasAiPreReviewResult) {
return submitBusy ? '审核中' : 'AI审核'
}
return submitBusy ? '提交中' : '下一步'
}
export function resolveSubmitActionIcon({ isApplicationDocument, hasAiPreReviewResult }) {
if (isApplicationDocument) {
return 'mdi mdi-send-circle-outline'
}
return hasAiPreReviewResult ? 'mdi mdi-arrow-right-circle-outline' : 'mdi mdi-shield-check-outline'
}
export function resolveSubmitConfirmDescription({ isApplicationDocument, aiPreReviewPassed }) {
if (isApplicationDocument) {
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (!aiPreReviewPassed) {
return 'AI预审存在重大风险请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
}
return 'AI预审已完成请确认费用明细、附件材料和风险说明均已核对无误。确认后将进入审批流程。'
}
export function resolveSubmitConfirmText(isApplicationDocument) {
return isApplicationDocument ? '确认提交' : '确认下一步'
}
export function resolveAiPreReviewToast(event) {
return event && (event.passed || event.status === 'passed')
? 'AI预审通过请点击下一步提交审批。'
: 'AI预审发现重大风险请核对 AI建议 后再点击下一步。'
}

View File

@@ -0,0 +1,30 @@
export function resolveSubmitActionLabel({
isApplicationDocument,
submitBusy
}) {
if (isApplicationDocument) {
return submitBusy ? '提交中' : '提交审批'
}
return submitBusy ? '提交中' : '提交审批'
}
export function resolveSubmitActionIcon({ isApplicationDocument }) {
if (isApplicationDocument) {
return 'mdi mdi-send-circle-outline'
}
return 'mdi mdi-send-circle-outline'
}
export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHighRiskWarnings }) {
if (isApplicationDocument) {
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
}
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
}
export function resolveSubmitConfirmText() {
return '确认提交'
}

View File

@@ -44,6 +44,7 @@ const FLOW_DURATION_SECOND_FIELDS = [
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
const FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS = 3000
function normalizeDurationValue(value, unit = 'ms') {
if (value === null || value === undefined || value === '') {
@@ -598,7 +599,7 @@ export function useTravelReimbursementFlow({
}
startFlowStep('pre-submit-review', {
title: 'AI预审与风险识别',
title: '自动检测与风险识别',
tool: 'ExpenseClaimService.submit_claim',
detail: '正在校验财务规则、风险规则和审批路径...'
})
@@ -665,6 +666,14 @@ export function useTravelReimbursementFlow({
tool: config.tool,
detail: config.detail
})
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewAction)) {
startFlowStep('draft-risk-review', {
title: '草稿风险识别',
tool: 'RuleEngine',
detail: '正在校验申请单关联、票据完整性、金额口径和行程一致性...'
})
}
}
function isApplicationSessionActive() {
@@ -685,6 +694,15 @@ export function useTravelReimbursementFlow({
)
}
function isDuplicateApplicationPayload(payload) {
if (!isApplicationSessionActive()) {
return false
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
return answer.includes('已存在申请单') && answer.includes('系统没有重复创建')
}
function buildApplicationSubmitSuccessDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
@@ -697,6 +715,55 @@ export function useTravelReimbursementFlow({
: `申请单提交成功,当前节点:${approvalStage}`
}
function buildApplicationDuplicateDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
const claimNo = answer.match(/AP-\d{14}-[A-HJ-NP-Z2-9]{8}/)?.[0] || ''
return claimNo
? `已拦截重复申请,已有申请单:${claimNo}`
: '已拦截重复申请,未创建新申请单'
}
function isSavedReimbursementDraftPayload(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
return Boolean(
draftPayload
&& String(draftPayload.status || '').trim() === 'draft'
&& String(draftPayload.draft_type || '').trim() !== 'expense_application'
)
}
function summarizeDraftRiskReviewDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const reviewPayload = result.review_payload && typeof result.review_payload === 'object'
? result.review_payload
: {}
const riskCount = Array.isArray(reviewPayload.risk_briefs)
? reviewPayload.risk_briefs.length
: Array.isArray(result.risk_flags)
? result.risk_flags.length
: 0
const missingCount = Array.isArray(reviewPayload.missing_slots)
? reviewPayload.missing_slots.length
: 0
const issueParts = []
if (riskCount) {
issueParts.push(`${riskCount} 条风险/异常提醒`)
}
if (missingCount) {
issueParts.push(`${missingCount} 项待补充信息`)
}
if (issueParts.length) {
return `已完成草稿规则校验,识别到 ${issueParts.join('、')},可进入详情核对后继续提交。`
}
return '已完成草稿规则校验,暂未发现明确风险;可继续上传票据或进入详情核对。'
}
function shouldHideToolCall(toolCall) {
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
@@ -750,9 +817,10 @@ export function useTravelReimbursementFlow({
response.submission_blocked ||
String(response.status || '').trim() === 'submitted' ||
responseMessage.includes('AI预审') ||
responseMessage.includes('自动检测') ||
responseMessage.includes('审批')
) {
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
return { key: 'pre-submit-review', title: '自动检测与风险识别', tool: 'ExpenseClaimService.submit_claim' }
}
if (responseMessage.includes('关联')) {
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
@@ -782,7 +850,7 @@ export function useTravelReimbursementFlow({
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
}
if (response.submission_blocked) {
return summarizeVisibleToolText(response.message) || 'AI预审发现待补充项,暂未提交审批'
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
}
return (
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
@@ -861,6 +929,30 @@ export function useTravelReimbursementFlow({
if (!answer && !payload?.result) {
return
}
if (isSubmittedApplicationPayload(payload)) {
completePendingFlowStep(
'application-submit-success',
buildApplicationSubmitSuccessDetail(payload),
null,
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
)
}
if (isDuplicateApplicationPayload(payload)) {
completePendingFlowStep(
'application-submit-success',
buildApplicationDuplicateDetail(payload),
null,
{ title: '重复申请已拦截', tool: 'ApplicationSubmit' }
)
}
if (isSavedReimbursementDraftPayload(payload)) {
completePendingFlowStep(
'draft-risk-review',
summarizeDraftRiskReviewDetail(payload),
null,
{ title: '草稿风险识别', tool: 'RuleEngine' }
)
}
const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
flowSteps.value
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
@@ -871,14 +963,6 @@ export function useTravelReimbursementFlow({
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
completeFlowStep(step.key, detail)
})
if (isSubmittedApplicationPayload(payload)) {
completePendingFlowStep(
'application-submit-success',
buildApplicationSubmitSuccessDetail(payload),
null,
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
)
}
const runFinishedAt = resolveFinishedTimestamp(run)
flowFinishedAt.value = flowSteps.value.some(
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
@@ -893,7 +977,15 @@ export function useTravelReimbursementFlow({
}
flowRefreshBusy.value = true
try {
const run = await fetchAgentRunDetail(flowRunId.value)
const run = await Promise.race([
fetchAgentRunDetail(flowRunId.value),
new Promise((resolve) => {
globalThis.setTimeout(() => resolve(null), FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS)
})
])
if (!run) {
return null
}
mergeFlowRunDetail(run)
return run
} catch (error) {

View File

@@ -228,7 +228,7 @@ export function useTravelReimbursementGuidedFlow({
function pushReimbursementSummary() {
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
meta: ['待生成核对信息'],
meta: ['待生成报销草稿'],
suggestedActions: buildGuidedReviewConfirmationActions()
})
}
@@ -286,6 +286,10 @@ export function useTravelReimbursementGuidedFlow({
const expenseTypeLabel = normalizeText(current.values.pending_scene_expense_type_label)
const applicationNo = normalizeText(current.values.application_claim_no)
const applicationId = normalizeText(current.values.application_claim_id)
const applicationReason = normalizeText(current.values.application_reason)
const applicationLocation = normalizeText(current.values.application_location)
const applicationAmount = normalizeText(current.values.application_amount || current.values.application_amount_label)
const applicationBusinessTime = normalizeText(current.values.application_business_time)
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
return null
}
@@ -299,11 +303,12 @@ export function useTravelReimbursementGuidedFlow({
return {
rawText,
userText: `关联申请单 ${applicationNo}`,
pendingText: `已关联申请单,正在${expenseTypeLabel}识别...`,
pendingText: `已关联申请单,正在生成${expenseTypeLabel}草稿...`,
systemGenerated: true,
skipUserMessage: true,
extraContext: {
draft_claim_id: '',
review_action: 'save_draft',
user_input_text: originalMessage,
expense_scene_selection: {
expense_type: current.expenseType || 'other',
@@ -314,11 +319,21 @@ export function useTravelReimbursementGuidedFlow({
},
review_form_values: {
expense_type: expenseTypeLabel,
reimbursement_type: expenseTypeLabel,
reason: applicationReason,
reason_value: applicationReason,
location: applicationLocation,
business_location: applicationLocation,
time_range: applicationBusinessTime,
business_time: applicationBusinessTime,
amount: applicationAmount,
application_claim_id: applicationId,
application_claim_no: applicationNo,
application_reason: current.values.application_reason || '',
application_location: current.values.application_location || '',
application_amount: current.values.application_amount || ''
application_reason: applicationReason,
application_location: applicationLocation,
application_amount: current.values.application_amount || '',
application_amount_label: current.values.application_amount_label || '',
application_business_time: applicationBusinessTime
}
}
}
@@ -329,6 +344,21 @@ export function useTravelReimbursementGuidedFlow({
const currentStep = getCurrentGuidedStep(currentState)
const fileNames = buildFileNames(files)
if (isGuidedReimbursementReadyForReview(currentState) && fileNames.length) {
const mergedFiles = mergePendingFiles(guidedPendingFiles.value, files)
guidedPendingFiles.value = mergedFiles
const submitOptions = {
...buildGuidedReviewSubmitOptions(currentState, mergedFiles),
skipDraftAssociationPrompt: true,
skipUserMessage: true,
pendingText: '已关联申请单,正在识别票据并生成报销草稿...'
}
resetGuidedFlowState()
persistAndScroll()
await submitExistingComposer(submitOptions)
return
}
if (currentState.stepKey === 'expense_type') {
const expenseType = resolveGuidedExpenseTypeFromText(answerText)
if (!expenseType) {
@@ -343,7 +373,7 @@ export function useTravelReimbursementGuidedFlow({
}
if (currentState.stepKey === 'application_selection') {
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我再继续询问报销依据。', {
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我会直接进入生成报销草稿。', {
meta: ['等待关联申请单'],
suggestedActions: buildRequiredApplicationActions(
currentState.applicationCandidates,
@@ -521,6 +551,11 @@ export function useTravelReimbursementGuidedFlow({
await submitExistingComposer(pendingSceneSubmitOptions)
return true
}
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
pushReimbursementSummary()
persistAndScroll()
return true
}
pushNextReimbursementPrompt()
persistAndScroll()
return true

View File

@@ -5,6 +5,7 @@ import {
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import {
applyApplicationBusinessTimeContext,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
@@ -58,6 +59,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
currentInsight,
currentUser,
draftClaimId,
emitDraftSaved,
emitOperationCompleted,
emitRequestUpdated,
extractReviewAttachmentNames,
@@ -139,6 +141,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function emitSavedDraftRefresh(draftPayload) {
if (!emitDraftSaved || isKnowledgeSession.value || !draftPayload?.claim_no) {
return
}
const draftType = String(draftPayload.draft_type || '').trim()
emitDraftSaved({
claimId: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
claimNo: String(draftPayload.claim_no || draftPayload.claimNo || '').trim(),
status: String(draftPayload.status || '').trim(),
approvalStage: String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim(),
documentType: draftType === 'expense_application' ? 'application' : 'reimbursement'
})
}
function normalizeRecognizedAttachmentData(data) {
if (!data || typeof data !== 'object') {
return null
@@ -351,9 +367,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText) {
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null) {
const user = await resolveApplicationPreviewUser()
const localPreview = buildLocalApplicationPreview(rawText, user)
const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
businessTimeContext
)
const enrichWithPolicyEstimate = async (preview) => {
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
@@ -393,11 +412,14 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
)
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
ontology,
rawText,
user
const refinedPreview = applyApplicationBusinessTimeContext(
buildModelRefinedApplicationPreview(
localPreview,
ontology,
rawText,
user
),
businessTimeContext
)
return {
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
@@ -462,6 +484,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const feedbackOperationType = String(options.feedbackOperationType || '').trim()
const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'
const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed ||
extraContext.attachment_association_confirmed ||
@@ -499,7 +522,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, { sessionType: activeSessionType.value }) && !reviewAction) {
if (shouldUseBudgetCompileReport(rawText, {
sessionType: activeSessionType.value,
entrySource: props.entrySource,
budgetContext: props.initialBudgetContext
}) && !reviewAction) {
return handleBudgetCompileReportSubmit({
adjustComposerTextareaHeight,
clearAttachedFiles,
@@ -518,6 +545,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
rawText,
replaceMessage,
resetFlowRun,
refreshCurrentUserFromBackend,
budgetContext: props.initialBudgetContext,
scrollToBottom,
startFlowStep,
submitting,
@@ -595,7 +624,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
submitting.value = true
try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText)
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText, selectedBusinessTimeContext)
const reviewStatus = String(meta?.[1] || '').trim()
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
@@ -725,7 +754,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} else {
clearFlowSimulationTimers()
}
if (rawText && !reviewAction) {
if (isApplicationSubmitOperation) {
startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
} else if (rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) {
startExpenseIntentConfirmationFlowPreview(rawText)
@@ -947,10 +982,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
extraContext.review_action = 'create_new_claim_from_documents'
}
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length,
waitForSceneSelection: waitForExpenseSceneSelection
})
if (!isApplicationSubmitOperation) {
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length,
waitForSceneSelection: waitForExpenseSceneSelection
})
}
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const orchestratorOptions = isKnowledgeSession.value
@@ -977,9 +1014,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
department: user.department || user.departmentName || '',
department_name: user.department || user.departmentName || '',
position: user.position || '',
grade: user.grade || '',
employee_position: user.position || user.employeePosition || user.employee_position || '',
employeePosition: user.position || user.employeePosition || user.employee_position || '',
grade: user.grade || user.employeeGrade || user.employee_grade || '',
employee_grade: user.grade || user.employeeGrade || user.employee_grade || '',
employeeGrade: user.grade || user.employeeGrade || user.employee_grade || '',
employee_no: user.employeeNo || user.employee_no || '',
employeeNo: user.employeeNo || user.employee_no || '',
manager_name: user.managerName || user.manager_name || '',
managerName: user.managerName || user.manager_name || '',
direct_manager_name: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
directManagerName: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
employee_location: user.location || '',
cost_center: user.costCenter || user.cost_center || '',
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
@@ -1051,6 +1096,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail)
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) {
emitSavedDraftRefresh(payload?.result?.draft_payload || null)
}
persistSessionState()
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()