feat: 同步报销流程与工作台改动

This commit is contained in:
caoxiaozhu
2026-06-09 08:32:00 +00:00
parent e124e4bbcb
commit 25724c354f
64 changed files with 6518 additions and 687 deletions

View File

@@ -39,6 +39,7 @@
width var(--sidebar-motion),
flex-basis var(--sidebar-motion),
box-shadow 160ms var(--ease);
animation: loginEntrySidebarIn 520ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.app.sidebar-collapsed .app-sidebar {
@@ -151,7 +152,13 @@
font-weight: 700;
}
.main { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.main {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
animation: loginEntryMainIn 620ms 90ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.main.overview-main {
height: var(--desktop-stage-height, 100dvh);
grid-template-rows: auto minmax(0, 1fr);
@@ -201,6 +208,14 @@
overflow-x: hidden;
overflow-y: auto;
padding: 20px 24px;
background-color: var(--workbench-surface-soft, #f9fbff);
background-image:
radial-gradient(ellipse at 0% 0%, rgba(58, 124, 165, 0.05) 0%, transparent 40%),
radial-gradient(ellipse at 100% 0%, rgba(110, 127, 166, 0.05) 0%, transparent 40%),
linear-gradient(rgba(58, 124, 165, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(58, 124, 165, 0.03) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px;
background-attachment: local;
}
.workarea.settings-workarea {
padding: 0;

View File

@@ -0,0 +1,574 @@
.progress-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.progress-panel :where(button) {
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
}
.progress-panel :where(button:disabled) {
cursor: not-allowed;
opacity: 0.7;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
margin-bottom: 5px;
}
.section-head h2 {
margin: 0;
color: var(--workbench-ink);
font-size: 16px;
line-height: 1.25;
font-weight: 850;
}
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 124px;
flex: 0 0 124px;
}
.progress-range-control {
position: relative;
z-index: 6;
flex: 0 0 124px;
}
.progress-range-select:deep(.el-select__wrapper) {
min-height: 32px;
border-radius: 4px;
box-shadow: 0 0 0 1px var(--workbench-line) inset;
background: rgba(255, 255, 255, 0.86);
}
.progress-range-select:deep(.el-select__wrapper.is-focused),
.progress-range-select:deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.38) inset;
}
.progress-table-shell {
position: relative;
display: grid;
--progress-table-columns: minmax(92px, 0.4fr) minmax(76px, 0.3fr) minmax(138px, 0.72fr) minmax(84px, 0.42fr) minmax(238px, 1.16fr) minmax(96px, 0.44fr);
grid-template-rows: 36px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.progress-table-header {
position: relative;
z-index: 3;
display: grid;
grid-template-columns: var(--progress-table-columns);
gap: 8px;
align-items: center;
height: 36px;
min-height: 36px;
max-height: 36px;
padding: 0 4px 0 0;
overflow: hidden;
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.94)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
box-shadow: 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
box-sizing: border-box;
}
.header-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
height: 100%;
overflow: hidden;
color: var(--workbench-muted);
font-size: 12px;
font-weight: 850;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-time {
padding-left: 0;
}
.header-applicant,
.header-type {
text-align: center;
}
.header-steps {
justify-content: center;
}
.header-result {
text-align: center;
padding-right: 0;
}
.progress-list {
position: relative;
z-index: 1;
display: grid;
min-height: 0;
height: 100%;
align-content: start;
grid-auto-rows: minmax(76px, auto);
overflow-x: hidden;
overflow-y: auto;
padding: 4px 4px 0 0;
scrollbar-width: thin;
scrollbar-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28) transparent;
}
.progress-list::-webkit-scrollbar {
width: 6px;
}
.progress-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
}
.progress-empty-state {
min-height: 220px;
height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px 18px;
border: 1px dashed rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.42)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025);
color: var(--workbench-muted);
text-align: center;
}
.progress-empty-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
font-size: 22px;
}
.progress-empty-state strong {
color: var(--workbench-ink);
font-size: 14px;
font-weight: 850;
}
.progress-empty-state p {
max-width: 260px;
margin: 0;
color: var(--workbench-muted);
font-size: 12px;
line-height: 1.55;
}
.progress-row {
position: relative;
display: grid;
grid-template-columns: var(--progress-table-columns);
align-items: center;
gap: 8px;
width: 100%;
min-height: 76px;
padding: 10px 0;
border-top: 0;
background: transparent;
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: inherit;
text-align: center;
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
}
.progress-row:first-child {
padding-top: 2px;
border-top: 0;
box-shadow: none;
}
.progress-row:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
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);
color: var(--workbench-primary-active);
}
.progress-time-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
min-width: 0;
}
.expense-type-icon {
position: relative;
flex-shrink: 0;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 22px;
box-shadow:
0 4px 10px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(0, 0, 0, 0.03);
}
.expense-type-icon::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%);
}
.expense-type-icon i {
position: relative;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.12));
}
.expense-type-icon--blue {
border: 1px solid color-mix(in srgb, var(--workbench-primary, #3a7ca5) 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-primary, #3a7ca5) 3%, #ffffff) 100%);
color: var(--workbench-primary, #3a7ca5);
}
.expense-type-icon--amber {
border: 1px solid color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff) 0%, color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 3%, #ffffff) 100%);
color: var(--workbench-chart-amber, #b58b4c);
}
.expense-type-icon--emerald {
border: 1px solid color-mix(in srgb, #0f8f68 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, #0f8f68 12%, #ffffff) 0%, color-mix(in srgb, #0f8f68 3%, #ffffff) 100%);
color: #0f8f68;
}
.expense-type-icon--violet {
border: 1px solid color-mix(in srgb, #6d5bd0 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, #6d5bd0 12%, #ffffff) 0%, color-mix(in srgb, #6d5bd0 3%, #ffffff) 100%);
color: #6d5bd0;
}
.expense-type-icon--cyan {
border: 1px solid color-mix(in srgb, #0788a2 20%, #ffffff);
background: linear-gradient(135deg, color-mix(in srgb, #0788a2 12%, #ffffff) 0%, color-mix(in srgb, #0788a2 3%, #ffffff) 100%);
color: #0788a2;
}
.expense-type-icon--muted {
border: 1px solid var(--workbench-line);
background: linear-gradient(135deg, var(--info-soft, #f1f5f9) 0%, #ffffff 100%);
color: var(--workbench-muted, #64748b);
}
.progress-time,
.progress-applicant,
.progress-identity,
.progress-type,
.progress-result {
min-width: 0;
display: grid;
gap: 2px;
}
.progress-time {
justify-items: center;
color: var(--workbench-muted);
}
.progress-applicant {
justify-items: center;
text-align: center;
}
.progress-applicant strong {
max-width: 100%;
overflow: hidden;
color: var(--workbench-ink);
font-size: 12.5px;
font-weight: 850;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-time time {
min-width: 0;
overflow: hidden;
color: var(--workbench-ink);
font-size: 12px;
font-weight: 850;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-identity {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-align: center;
}
.progress-identity strong {
margin-bottom: 2px;
}
.progress-identity strong,
.progress-result strong {
max-width: 100%;
overflow: hidden;
color: var(--workbench-ink);
font-size: 13px;
font-weight: bold;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-identity small {
max-width: 100%;
overflow: hidden;
color: var(--workbench-muted);
font-size: 11.5px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-type {
justify-self: stretch;
justify-items: center;
text-align: center;
}
.progress-type strong {
max-width: 100%;
min-height: 22px;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--workbench-primary-active);
font-size: 11.5px;
font-weight: 850;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-result {
justify-self: stretch;
align-items: center;
justify-content: center;
justify-items: center;
padding-right: 0;
text-align: center;
}
.progress-result strong {
width: 100%;
text-align: center;
}
.progress-steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
justify-items: stretch;
}
.progress-step {
position: relative;
display: grid;
justify-items: center;
gap: 1px;
color: color-mix(in srgb, var(--workbench-muted) 64%, #ffffff);
}
.progress-step::before {
content: "";
position: absolute;
top: 8px;
left: calc(-50% + 12px);
right: calc(50% + 12px);
height: 2px;
background: var(--workbench-line);
}
.progress-step:first-child::before {
display: none;
}
.progress-step.is-done::before,
.progress-step.is-current::before {
background: var(--workbench-primary);
}
.progress-step i {
position: relative;
z-index: 1;
width: 16px;
height: 16px;
display: grid;
place-items: center;
border: 2px solid var(--workbench-line);
border-radius: 999px;
background: var(--workbench-surface);
color: var(--workbench-line-strong, #cbd5e1);
font-size: 12px;
line-height: 1;
}
.progress-step.is-done i {
border-color: var(--workbench-primary);
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
}
.progress-step.is-current i {
border-color: var(--workbench-primary-active);
background: var(--theme-primary-light-9);
color: var(--workbench-primary-active);
}
.progress-step small {
color: currentColor;
font-size: 10px;
font-weight: 750;
line-height: 1.2;
white-space: nowrap;
}
.progress-step.is-done,
.progress-step.is-current {
color: var(--workbench-ink);
}
@media (max-width: 760px) {
.progress-table-shell {
grid-template-rows: minmax(0, 1fr);
}
.progress-table-header {
display: none;
}
.progress-row {
grid-template-columns: minmax(70px, auto) minmax(62px, auto) 1fr minmax(74px, auto);
grid-template-areas:
"time applicant identity result"
"type type type type"
"steps steps steps steps";
justify-items: center;
}
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 120px;
flex-basis: 120px;
}
.progress-range-control {
flex-basis: 120px;
}
.progress-empty-state {
min-height: 180px;
}
.progress-time-wrapper {
grid-area: time;
}
.progress-applicant {
grid-area: applicant;
}
.progress-identity {
grid-area: identity;
}
.progress-type {
grid-area: type;
width: 100%;
}
.progress-result {
grid-area: result;
width: 100%;
justify-items: center;
gap: 2px;
}
.progress-steps {
grid-area: steps;
width: 100%;
margin-top: 4px;
}
.progress-step i {
width: 14px;
height: 14px;
font-size: 10px;
}
.progress-step small {
font-size: 9px;
}
.progress-step::before {
top: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.progress-row {
animation: none !important;
}
}

View File

@@ -334,6 +334,19 @@
justify-items: start;
}
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 120px;
flex-basis: 120px;
}
.progress-empty-state {
min-height: 180px;
}
.progress-result {
justify-items: start;
}

View File

@@ -26,6 +26,8 @@
--workbench-chart-amber: var(--chart-amber, #b58b4c);
width: 100%;
max-width: 1680px;
margin: 0 auto;
height: 100%;
min-width: 0;
display: grid;
@@ -581,6 +583,27 @@
font-weight: 850;
}
.progress-section-head {
align-items: center;
}
.progress-range-select {
width: 124px;
flex: 0 0 124px;
}
.progress-range-select .el-select__wrapper {
min-height: 32px;
border-radius: 4px;
box-shadow: 0 0 0 1px var(--workbench-line) inset;
background: rgba(255, 255, 255, 0.86);
}
.progress-range-select .el-select__wrapper.is-focused,
.progress-range-select .el-select__wrapper:hover {
box-shadow: 0 0 0 1px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.38) inset;
}
.title-with-badge {
display: inline-flex;
align-items: center;
@@ -632,7 +655,65 @@
display: grid;
min-height: 0;
height: 100%;
grid-auto-rows: minmax(0, 1fr);
align-content: start;
grid-auto-rows: minmax(56px, auto);
overflow-x: hidden;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28) transparent;
}
.progress-list::-webkit-scrollbar {
width: 6px;
}
.progress-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
}
.progress-empty-state {
min-height: 220px;
height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px 18px;
border: 1px dashed rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.42)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025);
color: var(--workbench-muted);
text-align: center;
}
.progress-empty-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
font-size: 22px;
}
.progress-empty-state strong {
color: var(--workbench-ink);
font-size: 14px;
font-weight: 850;
}
.progress-empty-state p {
max-width: 260px;
margin: 0;
color: var(--workbench-muted);
font-size: 12px;
line-height: 1.55;
}
.progress-row:first-child {
@@ -645,15 +726,25 @@
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
}
.progress-identity,
.progress-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.progress-result {
gap: 12px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 12px;
}
.progress-identity strong {
margin-bottom: 2px;
}
.progress-identity strong,
.progress-result strong {
margin-bottom: 2px;
overflow: hidden;
color: var(--workbench-ink);
font-size: 13px;
@@ -751,45 +842,6 @@
text-align: left;
}
.progress-row.has-long-duration-divider {
margin-top: 13px;
padding-top: 13px;
border-top: 0;
}
.progress-row.has-long-duration-divider::before {
content: "10日以上";
position: absolute;
top: -9px;
left: 50%;
z-index: 1;
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 10px;
transform: translateX(-50%);
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
border-radius: 4px;
background: color-mix(in srgb, var(--theme-primary) 11%, #ffffff);
box-shadow: 0 4px 10px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
color: var(--theme-primary-active);
font-size: 11px;
font-weight: 850;
line-height: 1;
white-space: nowrap;
}
.progress-row.has-long-duration-divider::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.26);
pointer-events: none;
}
.progress-time-wrapper {
display: flex;
align-items: center;

View File

@@ -173,8 +173,32 @@
border-color 180ms var(--ease),
color 180ms var(--ease);
will-change: gap;
animation: navItemEntrance 500ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
@keyframes navItemEntrance {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.nav-btn:nth-child(1) { animation-delay: 60ms; }
.nav-btn:nth-child(2) { animation-delay: 110ms; }
.nav-btn:nth-child(3) { animation-delay: 160ms; }
.nav-btn:nth-child(4) { animation-delay: 210ms; }
.nav-btn:nth-child(5) { animation-delay: 260ms; }
.nav-btn:nth-child(6) { animation-delay: 310ms; }
.nav-btn:nth-child(7) { animation-delay: 360ms; }
.nav-btn:nth-child(8) { animation-delay: 410ms; }
.nav-btn:nth-child(9) { animation-delay: 460ms; }
.nav-btn:nth-child(10) { animation-delay: 510ms; }
.nav-btn:nth-child(11) { animation-delay: 560ms; }
.nav-btn:hover {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--theme-primary-active);

View File

@@ -3,7 +3,7 @@
:model-value="visible"
append-to-body
align-center
width="min(1040px, calc(100vw - 48px))"
width="min(960px, calc(100vw - 64px))"
:show-close="false"
:lock-scroll="true"
destroy-on-close
@@ -277,6 +277,8 @@ watch(
}
:global(.expense-profile-dialog.el-dialog) {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px;
@@ -380,7 +382,8 @@ watch(
}
.profile-dialog-content {
max-height: min(660px, calc(100vh - 190px));
max-height: min(580px, calc(100vh - 176px));
max-height: min(580px, calc(100dvh - 176px));
min-height: 0;
display: grid;
gap: 12px;
@@ -469,7 +472,7 @@ watch(
.profile-analysis-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.85fr);
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
gap: 12px;
}
@@ -483,13 +486,13 @@ watch(
.profile-tags-panel {
grid-template-rows: auto minmax(0, 1fr);
align-content: stretch;
min-height: 352px;
min-height: 312px;
}
.profile-radar-panel {
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: stretch;
min-height: 352px;
min-height: 312px;
}
.profile-section-title {
@@ -554,11 +557,11 @@ watch(
}
.profile-tags-panel > .profile-panel-empty {
min-height: 284px;
min-height: 244px;
}
.profile-radar-empty {
min-height: 308px;
min-height: 268px;
}
.profile-operation-copy strong {
@@ -580,13 +583,13 @@ watch(
grid-template-columns: minmax(0, 1fr);
align-items: center;
justify-items: stretch;
min-height: 360px;
min-height: 300px;
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
}
.profile-radar-chart {
width: 100%;
height: 360px;
height: 300px;
}
.profile-behavior-tags {
@@ -703,6 +706,97 @@ watch(
justify-self: end;
}
@media (min-width: 861px) and (max-width: 1440px),
(min-width: 861px) and (max-height: 820px) {
:global(.expense-profile-dialog.el-dialog) {
width: min(900px, calc(100vw - 96px)) !important;
max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
}
.profile-dialog-header,
.profile-dialog-footer {
gap: 12px;
padding: 12px 16px;
}
.profile-dialog-header h2 {
margin: 2px 0 3px;
font-size: 17px;
}
.profile-dialog-header p,
.profile-dialog-footer span {
font-size: 11.5px;
}
.profile-dialog-content {
max-height: min(520px, calc(100vh - 152px));
max-height: min(520px, calc(100dvh - 152px));
gap: 10px;
padding: 12px;
}
.profile-summary-grid,
.profile-analysis-grid {
gap: 8px;
}
.profile-summary-item {
gap: 3px;
padding: 8px 10px;
}
.profile-summary-item strong {
font-size: 16px;
}
.profile-panel {
gap: 8px;
padding: 10px;
}
.profile-analysis-grid {
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr);
}
.profile-tags-panel,
.profile-radar-panel {
min-height: 272px;
}
.profile-tags-panel > .profile-panel-empty {
min-height: 210px;
}
.profile-radar-empty {
min-height: 220px;
}
.profile-radar-layout {
min-height: 248px;
}
.profile-radar-chart {
height: 248px;
}
.profile-behavior-tags {
gap: 6px;
min-height: 50px;
padding-top: 8px;
}
.profile-operation-list {
gap: 6px;
}
.profile-operation-row {
gap: 8px;
padding: 7px 0;
}
}
@keyframes expenseProfileDialogIn {
0% {
opacity: 0;

View File

@@ -3,7 +3,7 @@
:model-value="visible"
append-to-body
align-center
width="min(980px, calc(100vw - 48px))"
width="min(900px, calc(100vw - 64px))"
:show-close="false"
:lock-scroll="true"
destroy-on-close
@@ -256,6 +256,8 @@ function resolveTagType(tone) {
}
:global(.expense-stats-detail-dialog.el-dialog) {
max-height: calc(100vh - 56px);
max-height: calc(100dvh - 56px);
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 4px;
@@ -353,7 +355,9 @@ function resolveTagType(tone) {
}
.expense-stats-detail-content {
max-height: min(660px, calc(100vh - 190px));
max-height: min(580px, calc(100vh - 176px));
max-height: min(580px, calc(100dvh - 176px));
min-height: 0;
display: grid;
gap: 12px;
padding: 14px;
@@ -372,7 +376,7 @@ function resolveTagType(tone) {
}
.expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(360px, 1.1fr);
grid-template-columns: minmax(0, 0.92fr) minmax(320px, 1.08fr);
}
.expense-stats-summary-item,
@@ -432,7 +436,7 @@ function resolveTagType(tone) {
.expense-stats-distribution-panel,
.expense-stats-processing-panel {
min-height: 336px;
min-height: 300px;
}
.expense-stats-section-title {
@@ -483,17 +487,17 @@ function resolveTagType(tone) {
}
.expense-distribution-chart {
min-height: 286px;
min-height: 250px;
display: grid;
align-items: stretch;
}
.expense-distribution-chart-layout {
display: grid;
grid-template-columns: minmax(170px, 0.86fr) minmax(0, 1.14fr);
grid-template-columns: minmax(160px, 0.86fr) minmax(0, 1.14fr);
align-items: center;
gap: 12px;
min-height: 286px;
min-height: 250px;
}
.expense-distribution-donut {
@@ -501,7 +505,7 @@ function resolveTagType(tone) {
}
.expense-distribution-donut :deep(.donut-body) {
height: 220px;
height: 192px;
margin-top: 0;
}
@@ -619,7 +623,7 @@ function resolveTagType(tone) {
.expense-stats-empty {
margin: 0;
min-height: 180px;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
@@ -632,6 +636,97 @@ function resolveTagType(tone) {
text-align: center;
}
@media (min-width: 861px) and (max-width: 1440px),
(min-width: 861px) and (max-height: 820px) {
:global(.expense-stats-detail-dialog.el-dialog) {
width: min(860px, calc(100vw - 96px)) !important;
max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
}
.expense-stats-detail-header,
.expense-stats-detail-footer {
gap: 12px;
padding: 12px 16px;
}
.expense-stats-detail-header h2 {
margin: 2px 0 3px;
font-size: 17px;
}
.expense-stats-detail-header p,
.expense-stats-detail-footer span {
font-size: 11.5px;
}
.expense-stats-detail-content {
max-height: min(520px, calc(100vh - 152px));
max-height: min(520px, calc(100dvh - 152px));
gap: 10px;
padding: 12px;
}
.expense-stats-summary-grid,
.expense-stats-analysis-grid {
gap: 8px;
}
.expense-stats-summary-item {
gap: 3px;
padding: 8px 10px;
}
.expense-stats-summary-item strong {
font-size: 16px;
}
.expense-stats-panel {
gap: 8px;
padding: 10px;
}
.expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.86fr) minmax(300px, 1.14fr);
}
.expense-stats-distribution-panel,
.expense-stats-processing-panel {
min-height: 272px;
}
.expense-distribution-chart,
.expense-distribution-chart-layout {
min-height: 222px;
}
.expense-distribution-chart-layout {
grid-template-columns: minmax(140px, 0.8fr) minmax(0, 1.2fr);
gap: 10px;
}
.expense-distribution-donut :deep(.donut-body) {
height: 170px;
}
.expense-distribution-summary-list,
.expense-processing-list,
.expense-operation-list {
gap: 6px;
}
.expense-distribution-summary-row,
.expense-processing-row,
.expense-operation-row {
gap: 8px;
padding: 7px 0;
}
.expense-processing-row {
grid-template-columns: minmax(112px, 0.78fr) minmax(148px, 1fr) auto 54px;
}
}
@keyframes expenseStatsDialogIn {
0% {
opacity: 0;

View File

@@ -199,65 +199,10 @@
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
<div class="section-head">
<h2>费用进度</h2>
</div>
<div class="progress-list">
<button
v-for="(item, index) in visibleProgressItems"
:key="item.id"
type="button"
class="progress-row"
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
:style="{ '--item-index': index }"
@click="openWorkbenchTarget(item)"
>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
<small v-if="item.showTimeCapsule" class="time-capsule">更新时间</small>
<small v-if="item.showUpdateText">更新</small>
</span>
</span>
<span class="progress-identity">
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
<span class="progress-result">
<strong>{{ item.amount }}</strong>
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
</span>
</button>
</div>
</article>
<PersonalWorkbenchProgressPanel
:progress-items="workbenchSummary.progressItems || []"
@open-target="openWorkbenchTarget"
/>
<aside class="side-column">
<article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;">
@@ -363,6 +308,7 @@ import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -520,92 +466,8 @@ const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.e
const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {})
const currentUserProfileKey = computed(() => {
const user = currentUser.value || {}
return [
user.username,
user.email,
user.name,
user.employeeNo,
user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems
: []
const progressRows = rows.slice(0, 5).map((item) => ({
...item,
displayTime: formatProgressTime(item?.updatedAt),
isLongDuration: isLongDurationProgress(item?.updatedAt)
}))
return progressRows.map((item, index) => {
const isCompleted = item.statusTone === 'muted';
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel);
return {
...item,
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone,
showTimeCapsule: !item.isLongDuration,
showUpdateText: item.isLongDuration && !isCompleted,
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
}
})
})
const LONG_DURATION_DAYS = 10
const DAY_MS = 24 * 60 * 60 * 1000
function formatProgressTime(value) {
const text = String(value || '').trim()
if (!text) {
return '最近更新'
}
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
if (match) {
return match[4] ? `${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[2]}-${match[3]}`
}
return text
}
function parseProgressDate(value) {
const text = String(value || '').trim()
if (!text) {
return null
}
const localDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (localDateMatch) {
return new Date(
Number(localDateMatch[1]),
Number(localDateMatch[2]) - 1,
Number(localDateMatch[3])
)
}
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function isLongDurationProgress(value) {
const date = parseProgressDate(value)
if (!date) {
return false
}
return (Date.now() - date.getTime()) / DAY_MS >= LONG_DURATION_DAYS
}
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
@@ -748,7 +610,7 @@ function openWorkbenchTarget(item) {
return
}
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''}费用进度`, SESSION_TYPE_EXPENSE)
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''}单据进度`, SESSION_TYPE_EXPENSE)
}
function openCapabilityAssistant(item) {

View File

@@ -0,0 +1,211 @@
<template>
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
<div class="section-head progress-section-head">
<h2>单据进度</h2>
<div
class="progress-range-control"
@click.stop
@mousedown.stop
@pointerdown.stop
>
<EnterpriseSelect
v-model="selectedProgressRange"
class="progress-range-select"
:options="PROGRESS_RANGE_OPTIONS"
size="small"
:teleported="true"
aria-label="单据进度时间范围"
/>
</div>
</div>
<div v-if="visibleProgressItems.length" class="progress-table-shell">
<div class="progress-table-header" aria-hidden="true">
<span class="header-cell header-time">更新时间</span>
<span class="header-cell header-applicant">提单人</span>
<span class="header-cell header-identity">单据信息</span>
<span class="header-cell header-type">类型归属</span>
<span class="header-cell header-steps">办理进度</span>
<span class="header-cell header-result">涉及金额</span>
</div>
<div class="progress-list">
<button
v-for="(item, index) in visibleProgressItems"
:key="`${item.id}-${index}`"
type="button"
class="progress-row"
:style="{ '--item-index': index }"
@click="handleProgressItemClick($event, item)"
>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
</span>
</span>
<span class="progress-applicant" title="申请人">
<strong>{{ item.applicantLabel || '待补充' }}</strong>
</span>
<span class="progress-identity">
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
<span class="progress-result">
<strong>{{ item.amount }}</strong>
</span>
</button>
</div>
</div>
<div v-else class="progress-empty-state" role="status">
<span class="progress-empty-icon" aria-hidden="true">
<i class="mdi mdi-file-document-search-outline"></i>
</span>
<strong>当前范围暂无单据</strong>
<p>{{ progressRangeLabel }}内没有申请单或报销单进度</p>
</div>
</article>
</template>
<script setup>
import { computed, ref } from 'vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const PROGRESS_RANGE_OPTIONS = Object.freeze([
{ value: '10d', label: '近10日' },
{ value: '30d', label: '近30日' },
{ value: '3m', label: '近3个月' }
])
const DAY_MS = 24 * 60 * 60 * 1000
const props = defineProps({
progressItems: { type: Array, default: () => [] }
})
const emit = defineEmits(['open-target'])
const selectedProgressRange = ref('30d')
const progressRangeLabel = computed(() =>
PROGRESS_RANGE_OPTIONS.find((item) => item.value === selectedProgressRange.value)?.label || '近30日'
)
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.progressItems) ? props.progressItems : []
return rows
.filter((item) => isInSelectedProgressRange(item?.updatedAt))
.map((item) => {
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel)
return {
...item,
displayTime: formatProgressTime(item?.updatedAt),
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone
}
})
})
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
function formatProgressTime(value) {
const text = String(value || '').trim()
if (!text) {
return '最近更新'
}
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
if (match) {
return match[4] ? `${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[2]}-${match[3]}`
}
return text
}
function parseProgressDate(value) {
const text = String(value || '').trim()
if (!text) {
return null
}
const localDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (localDateMatch) {
return new Date(
Number(localDateMatch[1]),
Number(localDateMatch[2]) - 1,
Number(localDateMatch[3])
)
}
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function resolveProgressRangeStart() {
const start = new Date()
start.setHours(0, 0, 0, 0)
if (selectedProgressRange.value === '10d') {
start.setDate(start.getDate() - 10)
return start
}
if (selectedProgressRange.value === '3m') {
start.setMonth(start.getMonth() - 3)
return start
}
start.setDate(start.getDate() - 30)
return start
}
function isInSelectedProgressRange(value) {
const date = parseProgressDate(value)
if (!date) {
return true
}
return date.getTime() >= resolveProgressRangeStart().getTime()
}
function handleProgressItemClick(event, item) {
const target = event?.target
if (target?.closest?.('.progress-range-control, .enterprise-select-popper, .el-select-dropdown, .el-popper')) {
return
}
emit('open-target', item)
}
</script>
<style scoped src="../../assets/styles/components/personal-workbench-progress.css"></style>

View File

@@ -32,7 +32,7 @@ const chartColors = computed(() => ({
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}登录${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}`
`${label}在线${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}`
)).join('')
)
@@ -84,13 +84,15 @@ const chartOptions = computed(() => ({
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
fontWeight: 700,
interval: props.compact ? 2 : 1,
hideOverlap: true
}
},
yAxis: [
{
type: 'value',
name: '登录',
name: '在线',
min: 0,
axisLabel: {
color: '#64748b',
@@ -115,7 +117,7 @@ const chartOptions = computed(() => ({
],
series: [
{
name: '登录人数',
name: '在线人数',
type: 'line',
smooth: 0.42,
symbol: 'circle',

View File

@@ -5,7 +5,7 @@ import { useNavigation, navItems } from './useNavigation.js'
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchExpenseClaimDetail } from '../services/reimbursements.js'
import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js'
import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
@@ -18,20 +18,20 @@ import {
} from '../utils/workbenchAssistantIntent.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const smartEntryOpen = ref(false)
const smartEntryContext = ref({
prompt: '',
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const smartEntryOpen = ref(false)
const smartEntryContext = ref({
prompt: '',
source: 'documents',
request: null,
request: null,
files: [],
conversation: null,
scope: null,
@@ -45,16 +45,17 @@ export function useAppShell() {
const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null)
const documentCenterRefreshToken = ref(0)
const { activeView, currentView, setView } = useNavigation()
const {
requests,
loading: requestsLoading,
error: requestsError,
search,
filters,
ranges,
activeRange,
const workbenchApprovalRequests = ref([])
const { activeView, currentView, setView } = useNavigation()
const {
requests,
loading: requestsLoading,
error: requestsError,
search,
filters,
ranges,
activeRange,
filteredRequests,
approveRequest,
rejectRequest,
@@ -65,7 +66,7 @@ export function useAppShell() {
const { toast } = useToast()
const customRange = ref(createCurrentYearDateRange())
const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '')
@@ -105,6 +106,40 @@ export function useAppShell() {
return reloadRequests()
}
async function reloadWorkbenchApprovalRequests() {
try {
const payload = await fetchAllApprovalExpenseClaims()
workbenchApprovalRequests.value = Array.isArray(payload)
? payload.map((item) => mapExpenseClaimToRequest(item))
: []
} catch {
workbenchApprovalRequests.value = []
}
}
async function reloadWorkbenchRequests() {
const [payload] = await Promise.all([
reloadRequests({ silent: true }),
reloadWorkbenchApprovalRequests()
])
return payload
}
function resolveWorkbenchRequestKey(request) {
return String(request?.claimId || request?.id || request?.claimNo || '').trim()
}
function mergeWorkbenchRequests(primaryRequests = [], approvalRequests = []) {
const merged = new Map()
for (const request of [...primaryRequests, ...approvalRequests]) {
const key = resolveWorkbenchRequestKey(request)
if (key) {
merged.set(key, request)
}
}
return Array.from(merged.values())
}
function isSameRequestIdentity(request, requestId) {
const normalizedId = String(requestId || '').trim()
if (!request || !normalizedId) {
@@ -185,16 +220,20 @@ export function useAppShell() {
return
}
if (view === 'workbench') {
void ensureRequestsLoaded()
void reloadWorkbenchRequests()
}
},
{ immediate: true }
)
const workbenchSummary = computed(() =>
buildWorkbenchSummary(requests.value, currentUser.value)
)
const workbenchRequests = computed(() =>
mergeWorkbenchRequests(requests.value, workbenchApprovalRequests.value)
)
const workbenchSummary = computed(() =>
buildWorkbenchSummary(workbenchRequests.value, currentUser.value)
)
const topBarView = computed(() => {
if (detailMode.value) {
const request = selectedRequest.value || {}
@@ -207,46 +246,46 @@ export function useAppShell() {
: '查看报销明细、票据材料、审批进度与风险提示。'
}
}
return currentView.value
})
const requestSummary = computed(() =>
filteredRequests.value.reduce(
(summary, item) => {
const request = normalizeRequestForUi(item)
if (!request) {
return summary
}
summary.total += 1
if (request.approvalKey === 'draft') {
summary.draft += 1
} else if (request.approvalKey === 'in_progress') {
summary.inProgress += 1
} else if (request.approvalKey === 'supplement') {
summary.supplement += 1
} else if (request.approvalKey === 'completed') {
summary.completed += 1
}
return summary
},
{ total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 }
)
)
function handleApprove(request) {
const message = approveRequest(request)
toast(message)
}
function handleReject(request) {
const message = rejectRequest(request)
toast(message)
}
const requestSummary = computed(() =>
filteredRequests.value.reduce(
(summary, item) => {
const request = normalizeRequestForUi(item)
if (!request) {
return summary
}
summary.total += 1
if (request.approvalKey === 'draft') {
summary.draft += 1
} else if (request.approvalKey === 'in_progress') {
summary.inProgress += 1
} else if (request.approvalKey === 'supplement') {
summary.supplement += 1
} else if (request.approvalKey === 'completed') {
summary.completed += 1
}
return summary
},
{ total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 }
)
)
function handleApprove(request) {
const message = approveRequest(request)
toast(message)
}
function handleReject(request) {
const message = rejectRequest(request)
toast(message)
}
function handleNavigate(view) {
smartEntryOpen.value = false
const shouldRefreshCurrentDocumentCenter =
@@ -258,7 +297,7 @@ export function useAppShell() {
void reloadDocumentCenterRequests()
}
}
function openFinancialAssistantCreate(source) {
if (smartEntryOpen.value) {
smartEntryRevealToken.value += 1
@@ -287,28 +326,28 @@ export function useAppShell() {
function openExpenseApplicationCreate() {
openFinancialAssistantCreate(SMART_ENTRY_SOURCE_APPLICATION)
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String(
payloadScope?.claimId ||
payloadScope?.claim_id ||
request?.claimId ||
request?.claim_id ||
''
).trim()
if (!claimId) {
return null
}
return { type: 'claim', claimId }
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String(
payloadScope?.claimId ||
payloadScope?.claim_id ||
request?.claimId ||
request?.claim_id ||
''
).trim()
if (!claimId) {
return null
}
return { type: 'claim', claimId }
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
@@ -451,7 +490,7 @@ export function useAppShell() {
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
await reloadRequests()
await reloadWorkbenchRequests()
if (status === 'submitted') {
if (isApplicationDocument) {
toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`)
@@ -505,7 +544,7 @@ export function useAppShell() {
}
async function handleRequestUpdated() {
await reloadRequests()
await reloadWorkbenchRequests()
await refreshSelectedRequestDetail(String(route.params.requestId || ''))
}

View File

@@ -18,9 +18,7 @@ import {
import {
metricBlueprints,
systemMetricBlueprints,
systemDashboardTotals as fallbackSystemDashboardTotals,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
systemLoginWave as fallbackSystemLoginWave,
systemTokenDailyWave as fallbackSystemTokenDailyWave,
systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
systemUserTokenUsage as fallbackSystemUserTokenUsage,
@@ -78,6 +76,25 @@ const emptyFinanceBudgetMetrics = [
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
]
const emptySystemDashboardTotals = {
toolCalls: 0,
modelTokens: 0,
onlineUsers: 0,
avgOnlineMinutes: 0,
executionSuccessRate: 0,
positiveFeedback: 0,
negativeFeedback: 0,
failedRuns: 0,
toolCallsChange: 0,
modelTokensChange: 0
}
const emptySystemLoginWave = {
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
loginUsers: Array.from({ length: 24 }, () => 0),
interactions: Array.from({ length: 24 }, () => 0)
}
function parseLocalDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
if (!match) {
@@ -439,13 +456,13 @@ export function useOverviewView(options = {}) {
})
const systemDashboardTotals = computed(() => (
systemDashboardPayload.value?.totals || fallbackSystemDashboardTotals
systemDashboardPayload.value?.totals || emptySystemDashboardTotals
))
const systemAgentDailyRatio = computed(() => (
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
))
const systemLoginWave = computed(() => (
systemDashboardPayload.value?.loginWave || fallbackSystemLoginWave
systemDashboardPayload.value?.loginWave || emptySystemLoginWave
))
const systemTokenDailyWave = computed(() => (
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave

View File

@@ -1,6 +1,6 @@
import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import { fetchAllExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = {
@@ -72,7 +72,6 @@ const APPLICATION_PROGRESS_LABELS = [
'创建申请',
'直属领导审批',
'预算管理者审批',
'审批完成',
APPLICATION_LINK_STATUS_STEP_LABEL,
ARCHIVED_STEP_LABEL
]
@@ -80,7 +79,6 @@ const APPLICATION_PROGRESS_LABELS = [
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
'创建申请',
'直属领导审批',
'审批完成',
APPLICATION_LINK_STATUS_STEP_LABEL,
ARCHIVED_STEP_LABEL
]
@@ -595,17 +593,17 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') {
return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 4 : 3
return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 3 : 2
}
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
return 4
return 3
}
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
return 3
return 2
}
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
return 3
return 2
}
if (normalizedNode.includes('预算')) {
return 2
@@ -693,6 +691,36 @@ function resolveApplicationApproverName(claim) {
) || '直属领导'
}
function resolveReimbursementApproverName(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '直属领导审批') {
return resolveDisplayName(
claim?.manager_name,
claim?.managerName,
claim?.profile_manager,
claim?.profileManager,
claim?.direct_manager_name,
claim?.directManagerName
) || '直属领导'
}
if (stepLabel === '财务审批') {
const routeEvent = findReimbursementFinanceRouteEvent(claim)
return resolveDisplayName(
claim?.finance_approver_name,
claim?.financeApproverName,
routeEvent?.next_approver_name,
routeEvent?.nextApproverName,
routeEvent?.finance_approver_name,
routeEvent?.financeApproverName,
claim?.finance_owner_name,
claim?.financeOwnerName
) || '财务'
}
return stepLabel.replace(/审批$/, '') || '审批人'
}
function resolveApplicationBudgetApproverName(claim) {
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
return resolveDisplayName(
@@ -708,6 +736,15 @@ function resolveApplicationBudgetApproverName(claim) {
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
const normalizedLabel = normalizeText(label)
const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
if (
documentTypeCode !== DOCUMENT_TYPE_APPLICATION
&& approvalMeta.key !== 'completed'
&& (normalizedLabel === '直属领导审批' || normalizedLabel === '财务审批')
&& workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
) {
return `等待 ${resolveReimbursementApproverName(claim, normalizedLabel)} 批复`
}
if (
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& approvalMeta.key !== 'completed'
@@ -796,6 +833,24 @@ function findApprovalEventForStep(claim, label) {
return getLatestEvent(events)
}
function findReimbursementFinanceRouteEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const source = normalizeText(flag.source)
if (!['manual_approval', 'budget_approval'].includes(source)) {
return false
}
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
return nextStage.includes('财务')
})
)
}
function findLatestReturnEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => (
@@ -1066,13 +1121,39 @@ function findMergedApplicationBudgetApprovalEvent(claim) {
source === 'manual_approval'
&& eventType === 'expense_application_approval'
&& previousStage.includes('直属领导')
&& nextStage.includes('审批完成')
&& (
nextStage.includes('审批完成')
|| nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL)
|| nextStage.includes('申请完成')
)
&& mergedFlag
)
})
)
}
function resolveBudgetRouteResult(flag, routeDecision = {}) {
if (routeDecision && typeof routeDecision === 'object') {
const routeBudgetResult = routeDecision.budget_result || routeDecision.budgetResult
if (routeBudgetResult && typeof routeBudgetResult === 'object') {
return routeBudgetResult
}
}
const flagBudgetResult = flag?.budget_result || flag?.budgetResult
return flagBudgetResult && typeof flagBudgetResult === 'object' ? flagBudgetResult : {}
}
function applicationBudgetRouteMeetsThreshold(flag, routeDecision = {}) {
const budgetResult = resolveBudgetRouteResult(flag, routeDecision)
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
const overBudgetAmount = parseNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
const afterUsageRate = parseNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
const claimAmountRatio = parseNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
return overBudgetAmount > 0 || Math.max(afterUsageRate, claimAmountRatio) >= 90
}
function applicationRequiresBudgetReviewStep(claim, workflowNode) {
const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
if (node.includes('预算')) {
@@ -1087,24 +1168,22 @@ function applicationRequiresBudgetReviewStep(claim, workflowNode) {
const source = normalizeText(flag.source)
const eventType = normalizeText(flag.event_type || flag.eventType)
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
const routeDecision = flag.route_decision || flag.routeDecision || {}
if (source === 'approval_routing' && flag.requires_budget_review === true) {
return true
return applicationBudgetRouteMeetsThreshold(flag, flag)
}
if (
routeDecision
&& typeof routeDecision === 'object'
&& routeDecision.requires_budget_review === true
) {
return true
return applicationBudgetRouteMeetsThreshold(flag, routeDecision)
}
return (
source === 'budget_approval'
|| eventType === 'expense_application_budget_approval'
|| previousStage.includes('预算')
|| nextStage.includes('预算')
)
})
}
@@ -1273,7 +1352,7 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交']
: hasMergedApplicationBudgetApproval
? ['创建申请', '直属领导审批', '审批完成', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
: shouldShowApplicationBudgetStep
? APPLICATION_PROGRESS_LABELS
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
@@ -1512,6 +1591,8 @@ export function mapExpenseClaimToRequest(claim) {
employeePosition: String(claim?.employee_position || '').trim(),
employeeGrade: String(claim?.employee_grade || '').trim(),
managerName: resolveDisplayName(claim?.manager_name),
financeApproverName: resolveDisplayName(claim?.finance_approver_name, claim?.financeApproverName),
financeOwnerName: resolveDisplayName(claim?.finance_owner_name, claim?.financeOwnerName),
budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
@@ -1665,19 +1746,26 @@ export function useRequests() {
})
})
async function reload() {
loading.value = true
error.value = ''
async function reload(options = {}) {
const silent = Boolean(options?.silent)
if (!silent) {
loading.value = true
error.value = ''
}
try {
const payload = await fetchExpenseClaims()
const payload = await fetchAllExpenseClaims()
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
loaded.value = true
} catch (nextError) {
requests.value = []
if (!silent) {
requests.value = []
}
error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。'
} finally {
loading.value = false
if (!silent) {
loading.value = false
}
}
}

View File

@@ -49,7 +49,10 @@ const router = createRouter({
},
{
path: '/app',
redirect: { name: 'app-overview' }
redirect: () => {
const { resolveEntryRoute } = useSystemState()
return resolveEntryRoute()
}
},
{
path: '/app/documents',

View File

@@ -1,6 +1,8 @@
import { apiRequest } from './api.js'
export const REIMBURSEMENT_LIST_PREVIEW_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
export const REIMBURSEMENT_LIST_FULL_PAGE_SIZE = 100
const REIMBURSEMENT_LIST_MAX_PAGES = 200
function buildListQuery(params = {}) {
const search = new URLSearchParams()
@@ -27,18 +29,73 @@ export function extractExpenseClaimItems(payload) {
return Array.isArray(payload?.items) ? payload.items : []
}
function isPaginatedExpenseClaimPayload(payload) {
return Boolean(
payload
&& typeof payload === 'object'
&& Array.isArray(payload.items)
&& Number.isFinite(Number(payload.page))
&& Number.isFinite(Number(payload.page_size))
)
}
async function fetchAllExpenseClaimPages(fetchPage, params = {}) {
const pageSize = Number(params.pageSize || params.page_size || REIMBURSEMENT_LIST_FULL_PAGE_SIZE)
const normalizedPageSize = Number.isFinite(pageSize) && pageSize > 0
? Math.min(REIMBURSEMENT_LIST_FULL_PAGE_SIZE, Math.floor(pageSize))
: REIMBURSEMENT_LIST_FULL_PAGE_SIZE
let page = Math.max(1, Math.floor(Number(params.page || 1) || 1))
const items = []
for (let index = 0; index < REIMBURSEMENT_LIST_MAX_PAGES; index += 1) {
const payload = await fetchPage({
...params,
page,
pageSize: normalizedPageSize
})
if (!isPaginatedExpenseClaimPayload(payload)) {
return extractExpenseClaimItems(payload)
}
items.push(...extractExpenseClaimItems(payload))
const total = Number(payload.total || 0)
const totalPages = Number(payload.total_pages || 0)
const hasNext = Boolean(payload.has_next)
if (!hasNext || (totalPages && page >= totalPages) || (total && items.length >= total)) {
break
}
page += 1
}
return items
}
export function fetchExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
}
export function fetchAllExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchExpenseClaims, params)
}
export function fetchApprovalExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`)
}
export function fetchAllApprovalExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchApprovalExpenseClaims, params)
}
export function fetchArchivedExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`)
}
export function fetchAllArchivedExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchArchivedExpenseClaims, params)
}
export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
}

View File

@@ -1,156 +1,156 @@
export const DEFAULT_APP_VIEW_ORDER = [
export const DEFAULT_APP_VIEW_ORDER = [
'workbench',
'documents',
'receiptFolder',
'budget',
'audit',
'audit',
'overview',
'policies',
'digitalEmployees',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'receiptFolder', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
budget: ['budget_monitor', 'executive'],
audit: ['finance'],
digitalEmployees: ['finance'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
const CLAIM_BUDGET_APPROVAL_GRADE = 'P8'
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes)
? user.roleCodes
.map((item) => normalizeRoleCode(item))
.filter(Boolean)
: []
}
function normalizeRoleCode(value) {
const roleCode = String(value || '').trim().toLowerCase()
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
}
function normalizeComparableText(value) {
return String(value || '').trim()
}
function collectIdentityNames(...values) {
return values
.map((value) => normalizeComparableText(value))
.filter(Boolean)
}
function identityIntersects(leftValues, rightValues) {
const rightSet = new Set(rightValues)
return leftValues.some((item) => rightSet.has(item))
}
function normalizedGrade(user) {
return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase()
}
function departmentIntersects(request, user) {
const requestDepartments = collectIdentityNames(
request?.dept,
request?.departmentName,
request?.department_name
)
const currentDepartments = collectIdentityNames(
user?.department,
user?.departmentName,
user?.department_name
)
return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments)
}
function hasPlatformAdminIdentity(user) {
if (!user) {
return false
}
const username = String(user.username || user.account || '').trim().toLowerCase()
const role = String(user.role || '').trim().toLowerCase()
const roleCodes = normalizedRoleCodes(user)
return (
Boolean(user.isAdmin)
|| username === 'admin'
|| role === 'admin'
|| role === '管理员'
|| role === '系统管理员'
|| roleCodes.includes('admin')
)
}
export function isManagerUser(user) {
return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager')
}
export function isPlatformAdminUser(user) {
return hasPlatformAdminIdentity(user)
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function isBudgetMonitorUser(user) {
return normalizedRoleCodes(user).includes('budget_monitor')
}
export function canEditBudgetCenter(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canSwitchBudgetDepartments(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canManageExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canDeleteArchivedExpenseClaims(user) {
return isPlatformAdminUser(user)
}
export function canReturnExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canApproveLeaderExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
const CLAIM_BUDGET_APPROVAL_GRADE = 'P8'
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes)
? user.roleCodes
.map((item) => normalizeRoleCode(item))
.filter(Boolean)
: []
}
function normalizeRoleCode(value) {
const roleCode = String(value || '').trim().toLowerCase()
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
}
function normalizeComparableText(value) {
return String(value || '').trim()
}
function collectIdentityNames(...values) {
return values
.map((value) => normalizeComparableText(value))
.filter(Boolean)
}
function identityIntersects(leftValues, rightValues) {
const rightSet = new Set(rightValues)
return leftValues.some((item) => rightSet.has(item))
}
function normalizedGrade(user) {
return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase()
}
function departmentIntersects(request, user) {
const requestDepartments = collectIdentityNames(
request?.dept,
request?.departmentName,
request?.department_name
)
const currentDepartments = collectIdentityNames(
user?.department,
user?.departmentName,
user?.department_name
)
return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments)
}
function hasPlatformAdminIdentity(user) {
if (!user) {
return false
}
const username = String(user.username || user.account || '').trim().toLowerCase()
const role = String(user.role || '').trim().toLowerCase()
const roleCodes = normalizedRoleCodes(user)
return (
Boolean(user.isAdmin)
|| username === 'admin'
|| role === 'admin'
|| role === '管理员'
|| role === '系统管理员'
|| roleCodes.includes('admin')
)
}
export function isManagerUser(user) {
return hasPlatformAdminIdentity(user) || normalizedRoleCodes(user).includes('manager')
}
export function isPlatformAdminUser(user) {
return hasPlatformAdminIdentity(user)
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function isBudgetMonitorUser(user) {
return normalizedRoleCodes(user).includes('budget_monitor')
}
export function canEditBudgetCenter(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canSwitchBudgetDepartments(user) {
return isPlatformAdminUser(user) || isExecutiveUser(user)
}
export function canManageExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canDeleteArchivedExpenseClaims(user) {
return isPlatformAdminUser(user)
}
export function canReturnExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canApproveLeaderExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canApproveBudgetExpenseApplications(user, request = null) {
if (isPlatformAdminUser(user)) {
return true
@@ -163,10 +163,10 @@ export function canApproveBudgetExpenseApplications(user, request = null) {
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
return false
}
return request ? departmentIntersects(request, user) : true
}
return request ? departmentIntersects(request, user) : true
}
export function isCurrentRequestApplicant(request, user) {
const applicantIds = collectIdentityNames(
request?.employeeId,
@@ -191,45 +191,45 @@ export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
request?.employee_name,
request?.profileName,
request?.applicant
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
}
export function isCurrentDirectManagerForRequest(request, user) {
if (isCurrentRequestApplicant(request, user)) {
return false
}
const managerNames = collectIdentityNames(
request?.profileManager,
request?.managerName,
request?.manager_name,
request?.directManagerName,
request?.direct_manager_name,
request?.manager
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
}
request?.employee_name,
request?.profileName,
request?.applicant
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
}
export function isCurrentDirectManagerForRequest(request, user) {
if (isCurrentRequestApplicant(request, user)) {
return false
}
const managerNames = collectIdentityNames(
request?.profileManager,
request?.managerName,
request?.manager_name,
request?.directManagerName,
request?.direct_manager_name,
request?.manager
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
@@ -246,35 +246,35 @@ export function canAccessAppView(user, viewId) {
if (viewId === 'budget') {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
}
if (isManagerUser(user)) {
return true
}
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true
}
const requiredRoles = VIEW_ROLE_RULES[viewId] || []
const roleCodes = normalizedRoleCodes(user)
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
}
export function getAccessibleViewIds(user) {
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
}
export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id))
}
}
const roleCodes = normalizedRoleCodes(user)
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
}
if (isManagerUser(user)) {
return true
}
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true
}
const requiredRoles = VIEW_ROLE_RULES[viewId] || []
const roleCodes = normalizedRoleCodes(user)
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
}
export function getAccessibleViewIds(user) {
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
}
export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id))
}
export function resolveDefaultAuthorizedRoute(user) {
if (isPlatformAdminUser(user) && canAccessAppView(user, 'overview')) {
return { name: 'app-overview' }
if (isPlatformAdminUser(user) && canAccessAppView(user, 'documents')) {
return { name: 'app-documents' }
}
const firstVisibleView = getAccessibleViewIds(user)[0]

View File

@@ -1,4 +1,5 @@
import { isApplicationRequestLike } from './documentClassification.js'
import { canProcessApprovalRequest } from './approvalInbox.js'
function parseNumber(value) {
const nextValue = Number(value)
@@ -46,6 +47,15 @@ export function belongsToCurrentUser(request, currentUser) {
return names.some((name) => name === person)
}
function belongsToWorkbenchProgressScope(request, currentUser) {
if (belongsToCurrentUser(request, currentUser)) {
return true
}
return normalizeText(request?.approvalKey) === 'in_progress'
&& canProcessApprovalRequest(request, currentUser)
}
export function hasHighRiskFlag(request) {
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
@@ -133,6 +143,16 @@ function resolveRequestTarget(request) {
}
}
function resolveApplicantLabel(request) {
return normalizeText(
request?.person
|| request?.employeeName
|| request?.employee_name
|| request?.applicant
|| request?.profileName
) || '待补充'
}
function resolveStatusTone(approvalKey) {
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
if (approvalKey === 'draft') return 'success'
@@ -254,31 +274,84 @@ export function buildAdjacentProgressSteps(steps = [], windowSize = 4) {
}))
}
const FALLBACK_APPLICATION_PROGRESS_LABELS = Object.freeze([
'创建申请',
'直属领导审批',
'关联单据状态',
'已归档'
])
const FALLBACK_REIMBURSEMENT_PROGRESS_LABELS = Object.freeze([
'关联单据',
'待提交',
'直属领导审批',
'财务审批',
'待付款',
'已付款',
'已归档'
])
function resolveFallbackProgressCurrentIndex(labels, request, documentTypeLabel) {
const approvalKey = normalizeText(request?.approvalKey)
const status = normalizeText(request?.approvalStatus || request?.status || request?.workflowNode)
if (documentTypeLabel === '申请单') {
if (/归档/.test(status)) return labels.indexOf('已归档')
if (approvalKey === 'completed' || /完成|通过/.test(status)) return labels.indexOf('关联单据状态')
if (/直属领导|领导审批|负责人/.test(status)) return labels.indexOf('直属领导审批')
return 0
}
if (approvalKey === 'completed' || /归档|已付款|完成/.test(status)) return labels.indexOf('已归档')
if (approvalKey === 'pending_payment' || /待付款|待支付/.test(status)) return labels.indexOf('待付款')
if (/财务/.test(status)) return labels.indexOf('财务审批')
if (/直属领导|领导审批|负责人/.test(status)) return labels.indexOf('直属领导审批')
return labels.indexOf('待提交')
}
function buildFallbackProgressSteps(request, documentTypeLabel) {
const labels = documentTypeLabel === '申请单'
? FALLBACK_APPLICATION_PROGRESS_LABELS
: FALLBACK_REIMBURSEMENT_PROGRESS_LABELS
const currentIndex = Math.max(0, resolveFallbackProgressCurrentIndex(labels, request, documentTypeLabel))
return labels.map((label, index) => ({
label,
rawLabel: label,
done: index < currentIndex,
active: index <= currentIndex,
current: index === currentIndex,
title: index === currentIndex ? '进行中' : index < currentIndex ? '已完成' : '待处理'
}))
}
function buildProgressItems(ownedRequests) {
return ownedRequests
.filter((request) => Array.isArray(request?.progressSteps) && request.progressSteps.length)
.map((request) => {
const requestId = resolveRequestIdentity(request)
const steps = buildAdjacentProgressSteps(request.progressSteps, 4)
const currentStep = steps.find((step) => step.current)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
const sourceSteps = Array.isArray(request?.progressSteps) && request.progressSteps.length
? request.progressSteps
: buildFallbackProgressSteps(request, documentTypeLabel)
const steps = buildAdjacentProgressSteps(sourceSteps, 4)
const currentStep = steps.find((step) => step.current)
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
return {
id: requestId,
requestId,
title,
documentTypeLabel,
applicantLabel: resolveApplicantLabel(request),
expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount),
status,
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt || request?.applyTime),
steps,
target: resolveRequestTarget(request),
prompt: `查询 ${requestId || title}费用进度`
prompt: `查询 ${requestId || title}单据进度`
}
})
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
@@ -486,9 +559,11 @@ function buildExpenseOperationRows(todoItems, notifications, progressItems) {
}
export function buildWorkbenchSummary(requests, currentUser) {
const ownedRequests = Array.isArray(requests)
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
const allRequests = Array.isArray(requests)
? requests
: []
const ownedRequests = allRequests.filter((item) => belongsToCurrentUser(item, currentUser))
const progressRequests = allRequests.filter((item) => belongsToWorkbenchProgressScope(item, currentUser))
const monthlyClaims = ownedRequests.filter((item) => isCurrentMonth(resolveClaimDate(item)))
@@ -505,7 +580,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
const todoItems = buildTodoItems(ownedRequests)
const progressItems = buildProgressItems(ownedRequests)
const progressItems = buildProgressItems(progressRequests)
const notifications = buildNotifications(todoItems, progressItems)
const expenseStatsDetail = {
distributionRows: buildExpenseDistributionRows(ownedRequests),

View File

@@ -142,7 +142,7 @@
<DocumentsCenterView
v-else-if="activeView === 'documents'"
:filtered-requests="filteredRequests"
:filtered-requests="requests"
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"

View File

@@ -249,10 +249,9 @@ import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims,
fetchArchivedExpenseClaims
fetchAllApprovalExpenseClaims,
fetchAllArchivedExpenseClaims
} from '../services/reimbursements.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import {
@@ -925,8 +924,8 @@ async function loadSupportingRows() {
supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
fetchAllApprovalExpenseClaims(),
fetchAllArchivedExpenseClaims()
])
if (approvalResult.status === 'fulfilled') {

View File

@@ -257,7 +257,7 @@
<div class="card-head">
<h3>用户在线波动 <i class="mdi mdi-information-outline"></i></h3>
</div>
<p class="card-subtitle">登录人数与互动次数的时段波动</p>
<p class="card-subtitle">在线人数与互动次数的时段波动</p>
<SystemLoginWaveChart
compact

View File

@@ -755,10 +755,7 @@ export default {
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
}
const canProcessCurrentApprovalStage = computed(() => {
if (isDirectManagerApprovalStage.value) {
return isCurrentDirectManagerApprover.value
}
@@ -767,18 +764,16 @@ export default {
}
return canProcessFinanceApprovalStage.value
})
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
}
return canProcessCurrentApprovalStage.value
})
const canApproveRequest = computed(() =>
(Boolean(props.approvalMode) || isApplicationDocument.value)
&& request.value.approvalKey === 'in_progress'
request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
(
isDirectManagerApprovalStage.value
&& isCurrentDirectManagerApprover.value
)
|| canProcessFinanceApprovalStage.value
|| canProcessBudgetApprovalStage.value
)
&& canProcessCurrentApprovalStage.value
)
const canViewApprovalRiskAdvice = computed(() => (
Boolean(request.value.claimId)

View File

@@ -183,6 +183,7 @@ export function buildFallbackProgressSteps(requestModel = {}) {
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
const paid = /已付款/.test(node)
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
const archived = /申请归档|已归档/.test(node)
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
if (isApplicationDocumentRequest(requestModel)) {
@@ -207,11 +208,19 @@ export function buildFallbackProgressSteps(requestModel = {}) {
},
{
index: 3,
label: '审批完成',
time: completed ? '已完成' : '待处理',
done: completed,
active: completed,
current: false
label: '关联单据状态',
time: hasRelatedApplication ? '已关联' : completed ? '未关联' : '待处理',
done: archived,
active: completed || archived,
current: completed && !archived
},
{
index: 4,
label: '已归档',
time: archived ? '已完成' : '待处理',
done: archived,
active: archived,
current: archived
}
]
}
@@ -597,6 +606,7 @@ export function buildExpenseDraftIssues(item) {
export function buildDraftBlockingIssues(request, expenseItems) {
const issues = []
const isApplication = isApplicationDocumentRequest(request)
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
const itemAmountTotal = normalizedItems.reduce((sum, item) => {
@@ -611,6 +621,25 @@ export function buildDraftBlockingIssues(request, expenseItems) {
if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善')
}
if (isApplication) {
if (isPlaceholderValue(request.typeLabel)) {
issues.push('申请类型未完善')
}
if (isPlaceholderValue(request.reason)) {
issues.push('申请事由未完善')
}
if (isPlaceholderValue(request.location)) {
issues.push('业务地点未完善')
}
if (isPlaceholderValue(request.occurredDisplay)) {
issues.push('申请时间未完善')
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
issues.push('预计总费用未完善')
}
return [...new Set(issues)]
}
if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) {
issues.push('报销类型未完善')
}
@@ -657,18 +686,30 @@ export function mapIssueToAdvice(issue) {
if (text === '报销类型未完善') {
return '选择报销类型,明确本次费用归类。'
}
if (text === '申请类型未完善') {
return '补充申请类型,明确本次申请的费用或业务场景。'
}
if (text === '报销事由未完善') {
return '补充报销事由,说明本次费用用途。'
}
if (text === '申请事由未完善') {
return '补充申请事由,说明本次申请的业务背景。'
}
if (text === '业务地点未完善') {
return '补充业务地点,方便审核业务发生场景。'
}
if (text === '发生时间未完善') {
return '补充费用发生时间,确保单据时间完整。'
}
if (text === '申请时间未完善') {
return '补充申请时间或行程时间,确保申请周期完整。'
}
if (text === '报销金额未完善') {
return '补充报销金额,并与费用明细金额保持一致。'
}
if (text === '预计总费用未完善') {
return '补充预计总费用,供审批人判断预算占用和申请额度。'
}
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
if (!itemMatch) {