feat: 同步报销流程与工作台改动
This commit is contained in:
@@ -48,4 +48,8 @@ SQLALCHEMY_ECHO=false
|
|||||||
REDIS_URL=
|
REDIS_URL=
|
||||||
VITE_REDIS_URL=
|
VITE_REDIS_URL=
|
||||||
|
|
||||||
|
OCR_DEVICE=
|
||||||
|
OCR_TIMEOUT_SECONDS=180
|
||||||
|
OCR_MAX_CONCURRENT_WORKERS=1
|
||||||
|
|
||||||
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]'
|
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]'
|
||||||
|
|||||||
8
docker-compose.gpu.yml
Normal file
8
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
main:
|
||||||
|
gpus: all
|
||||||
|
shm_size: "8gb"
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
OCR_DEVICE: "${OCR_DEVICE:-gpu:0}"
|
||||||
29
docker-compose.postgres.yml
Normal file
29
docker-compose.postgres.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
main:
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
container_name: x-financial-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: "${POSTGRES_DB:-x_financial}"
|
||||||
|
POSTGRES_USER: "${POSTGRES_USER:-x_financial}"
|
||||||
|
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-x_financial}"
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_HOST_PORT:-55432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- financial-internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
1177
mobile/design/android-expense-assistant-screens.html
Normal file
1177
mobile/design/android-expense-assistant-screens.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mobile/design/android-expense-assistant-screens.png
Normal file
BIN
mobile/design/android-expense-assistant-screens.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
1338
mobile/design/android-expense-assistant-v2.html
Normal file
1338
mobile/design/android-expense-assistant-v2.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mobile/design/android-expense-assistant-v2.png
Normal file
BIN
mobile/design/android-expense-assistant-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 734 KiB |
724
mobile/design/android-expense-splash-assets.html
Normal file
724
mobile/design/android-expense-splash-assets.html
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>X-Financial Expense 启动页素材系统</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #edf2f0;
|
||||||
|
--ink: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--line: #d8e1e8;
|
||||||
|
--primary: #006b5e;
|
||||||
|
--primary-2: #0f766e;
|
||||||
|
--primary-dark: #061416;
|
||||||
|
--mint: #e1f6f1;
|
||||||
|
--gold: #f5a524;
|
||||||
|
--gold-soft: #fff2cf;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--blue-soft: #e7efff;
|
||||||
|
--paper: #f8fafc;
|
||||||
|
--shadow: 0 24px 70px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px),
|
||||||
|
var(--bg);
|
||||||
|
background-size: 28px 28px;
|
||||||
|
font-family: "IBM Plex Sans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 34px 30px 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
gap: 22px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.86);
|
||||||
|
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-main {
|
||||||
|
min-height: 260px;
|
||||||
|
padding: 30px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0, 107, 94, 0.18);
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--mint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 14px 0 10px;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p,
|
||||||
|
.section p {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.62;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-side {
|
||||||
|
padding: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkline strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkline span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon.gold { color: #8a5a00; background: var(--gold-soft); }
|
||||||
|
.icon.blue { color: var(--blue); background: var(--blue-soft); }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 420px minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
width: 316px;
|
||||||
|
height: 684px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 34px;
|
||||||
|
background: #101827;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 27px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys { display: flex; gap: 5px; align-items: center; }
|
||||||
|
.sig, .wifi, .bat { display: inline-block; border: 1.7px solid currentColor; }
|
||||||
|
.sig { width: 12px; height: 10px; border-top: 0; border-left: 0; transform: skew(-8deg); }
|
||||||
|
.wifi { width: 13px; height: 8px; border-bottom: 0; border-left-color: transparent; border-right-color: transparent; border-radius: 14px 14px 0 0; }
|
||||||
|
.bat { width: 18px; height: 10px; border-radius: 2px; background: linear-gradient(90deg, currentColor 68%, transparent 68%); }
|
||||||
|
|
||||||
|
.splash {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100% - 30px);
|
||||||
|
padding: 48px 16px 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -34px -18px auto;
|
||||||
|
height: 320px;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 28% 18%, rgba(245, 165, 36, 0.18), transparent 26%),
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,0.12), transparent 34%),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px);
|
||||||
|
background-size: auto, auto, 24px 24px, 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: rgba(255,255,255,0.68);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockup {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 42px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 74px;
|
||||||
|
height: 74px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(145deg, #0b3b36, #00806f);
|
||||||
|
box-shadow: 0 24px 58px rgba(0,0,0,0.36), 0 0 0 1px rgba(255,255,255,0.16) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark::before,
|
||||||
|
.brand-mark::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
transform: rotate(28deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark::before { left: 20px; top: 15px; }
|
||||||
|
.brand-mark::after { right: 20px; bottom: 15px; opacity: 0.72; }
|
||||||
|
|
||||||
|
.splash-name {
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 1.05;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-tag {
|
||||||
|
max-width: 230px;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgba(255,255,255,0.68);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.48;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene {
|
||||||
|
position: relative;
|
||||||
|
height: 252px;
|
||||||
|
margin-top: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.13);
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px),
|
||||||
|
linear-gradient(145deg, #062425, #0a5f56 58%, #103a36);
|
||||||
|
background-size: 22px 22px, 22px 22px, auto;
|
||||||
|
box-shadow: 0 22px 50px rgba(0,0,0,0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device {
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
top: 28px;
|
||||||
|
width: 128px;
|
||||||
|
height: 188px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
background: rgba(3, 21, 24, 0.72);
|
||||||
|
box-shadow: 0 18px 36px rgba(0,0,0,0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device span {
|
||||||
|
display: block;
|
||||||
|
height: 8px;
|
||||||
|
margin: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device span:first-child {
|
||||||
|
width: 72%;
|
||||||
|
margin-top: 26px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 18px;
|
||||||
|
padding: 11px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount small {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: rgba(255,255,255,0.52);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card {
|
||||||
|
position: absolute;
|
||||||
|
right: 15px;
|
||||||
|
top: 42px;
|
||||||
|
width: 156px;
|
||||||
|
padding: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.94);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 18px 42px rgba(0,0,0,0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card.secondary {
|
||||||
|
top: 132px;
|
||||||
|
right: 34px;
|
||||||
|
width: 174px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #8a5a00;
|
||||||
|
background: var(--gold-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card h3 {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-pill {
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #d7fff7;
|
||||||
|
background: rgba(0, 91, 79, 0.75);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
width: 118px;
|
||||||
|
height: 4px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 66%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #dffcf4, var(--gold));
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-copy {
|
||||||
|
color: rgba(255,255,255,0.68);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset {
|
||||||
|
min-height: 138px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
display: grid;
|
||||||
|
align-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset.dark {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--primary-dark);
|
||||||
|
border-color: rgba(255,255,255,0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset.dark .asset-meta { color: rgba(255,255,255,0.62); }
|
||||||
|
|
||||||
|
.app-icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 22%;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, #0b3b36, #00806f);
|
||||||
|
box-shadow: 0 16px 38px rgba(0, 91, 79, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon::before,
|
||||||
|
.app-icon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
transform: rotate(28deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon::before { left: 27%; top: 20%; width: 22%; height: 56%; }
|
||||||
|
.app-icon::after { right: 27%; bottom: 20%; width: 22%; height: 56%; opacity: 0.72; }
|
||||||
|
.app-icon.lg { width: 96px; height: 96px; }
|
||||||
|
.app-icon.md { width: 72px; height: 72px; }
|
||||||
|
.app-icon.sm { width: 48px; height: 48px; }
|
||||||
|
|
||||||
|
.spec-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec strong {
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 950;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec span {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motion {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-scene {
|
||||||
|
height: 72px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(145deg, #062425, #0a5f56);
|
||||||
|
background-size: 18px 18px, 18px 18px, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(15,23,42,0.1);
|
||||||
|
display: grid;
|
||||||
|
align-content: end;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch.light { color: var(--ink); }
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.hero,
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.phone { margin-left: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.wrap { padding: 22px 12px 40px; }
|
||||||
|
h1 { font-size: 28px; }
|
||||||
|
.asset-grid,
|
||||||
|
.motion,
|
||||||
|
.color-row { grid-template-columns: 1fr; }
|
||||||
|
.spec { grid-template-columns: 1fr; }
|
||||||
|
.phone { width: min(316px, 100%); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="panel hero-main">
|
||||||
|
<span class="kicker">Splash asset system</span>
|
||||||
|
<h1>X-Financial Expense 启动页素材系统</h1>
|
||||||
|
<p>这页专门给 Android 开机页使用,不直接复用业务页面卡片。素材分成品牌锁定、深色背景、票据场景、启动动效和 Android 落地规格,后续开发 Compose 或原生启动页时按这里取值。</p>
|
||||||
|
</div>
|
||||||
|
<aside class="panel hero-side">
|
||||||
|
<div class="checkline"><div class="icon">01</div><div><strong>启动页先看品牌</strong><span>Logo、产品名和安全工作台定位占主导,不放登录表单。</span></div></div>
|
||||||
|
<div class="checkline"><div class="icon gold">02</div><div><strong>业务只做隐喻</strong><span>票据、审批、金额作为启动素材出现,不承载真实操作。</span></div></div>
|
||||||
|
<div class="checkline"><div class="icon blue">03</div><div><strong>可直接落 Android</strong><span>提供颜色、图标尺寸、动效节奏和 SplashScreen API 参数。</span></div></div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel section">
|
||||||
|
<h2>启动页预览</h2>
|
||||||
|
<p>用于 9:16 Android 手机首屏,登录态检查完成后跳转登录页或首页。</p>
|
||||||
|
<div style="height:16px"></div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="screen">
|
||||||
|
<div class="status"><span>9:41</span><span class="sys"><i class="sig"></i><i class="wifi"></i><i class="bat"></i></span></div>
|
||||||
|
<div class="splash">
|
||||||
|
<div class="topline"><span>SECURE EXPENSE</span><span>ANDROID</span></div>
|
||||||
|
<div class="lockup">
|
||||||
|
<div class="brand-mark"></div>
|
||||||
|
<div>
|
||||||
|
<div class="splash-name">X-Financial<br/>Expense</div>
|
||||||
|
<div class="splash-tag">出差申请、票据采集、报销提交、移动审批</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scene">
|
||||||
|
<div class="device">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
<div class="amount"><small>本月待处理</small>¥ 3,280</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-card">
|
||||||
|
<span class="chip">AI 识别</span>
|
||||||
|
<h3>票据已归类</h3>
|
||||||
|
<p>5 张票据 · 2 项待补</p>
|
||||||
|
</div>
|
||||||
|
<div class="asset-card secondary">
|
||||||
|
<h3>审批流已同步</h3>
|
||||||
|
<p>财务复核中 · 待提醒</p>
|
||||||
|
</div>
|
||||||
|
<div class="float-pill">拍照上传</div>
|
||||||
|
</div>
|
||||||
|
<div class="splash-footer">
|
||||||
|
<div class="progress"></div>
|
||||||
|
<div class="small-copy">正在加载安全工作台</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<section class="panel section">
|
||||||
|
<h2>品牌与 App 图标</h2>
|
||||||
|
<p>App 图标保留双斜票据形态,避免直接使用文字。启动页 Logo 使用 74dp 等比缩放,图标安全区不低于 12dp。</p>
|
||||||
|
<div class="app-icons">
|
||||||
|
<div class="app-icon lg"></div>
|
||||||
|
<div class="app-icon md"></div>
|
||||||
|
<div class="app-icon sm"></div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-grid">
|
||||||
|
<div class="asset"><div class="brand-mark" style="width:58px;height:58px;border-radius:17px"></div><div><div class="asset-title">启动 Logo</div><div class="asset-meta">74dp / 58dp / 44dp 三档,深色背景使用。</div></div></div>
|
||||||
|
<div class="asset dark"><div class="app-icon md"></div><div><div class="asset-title">Launcher Icon</div><div class="asset-meta">前景图形居中,保留 Android 自适应图标裁切空间。</div></div></div>
|
||||||
|
<div class="asset"><div class="chip">AI 识别</div><div><div class="asset-title">业务状态 Chip</div><div class="asset-meta">仅作为视觉线索,不做主操作入口。</div></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel section">
|
||||||
|
<h2>颜色与背景</h2>
|
||||||
|
<p>启动页走深色金融安全感。绿色做品牌,金色只用于识别和进度强调,蓝色用于信息状态。</p>
|
||||||
|
<div class="color-row">
|
||||||
|
<div class="swatch" style="background:#061416">#061416<br/>启动底色</div>
|
||||||
|
<div class="swatch" style="background:#006b5e">#006B5E<br/>品牌主色</div>
|
||||||
|
<div class="swatch" style="background:#0f766e">#0F766E<br/>场景渐变</div>
|
||||||
|
<div class="swatch" style="background:#f5a524;color:#251500">#F5A524<br/>识别强调</div>
|
||||||
|
<div class="swatch light" style="background:#f8fafc">#F8FAFC<br/>票据纸面</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel section">
|
||||||
|
<h2>素材拆分</h2>
|
||||||
|
<p>开发落地时建议拆成 5 个可复用素材层,Compose 中可分别写成 Composable。</p>
|
||||||
|
<div class="asset-grid">
|
||||||
|
<div class="asset dark"><div class="mini-scene"></div><div><div class="asset-title">背景层</div><div class="asset-meta">深色底、细网格、顶部柔光,静态即可。</div></div></div>
|
||||||
|
<div class="asset"><div class="asset-card" style="position:static;width:auto;box-shadow:none;border:1px solid var(--line)"><span class="chip">AI 识别</span><h3>票据已归类</h3><p>5 张票据 · 2 项待补</p></div><div><div class="asset-title">票据层</div><div class="asset-meta">表达拍照上传和 OCR,不显示真实敏感票据信息。</div></div></div>
|
||||||
|
<div class="asset"><div class="float-pill" style="position:static;color:#006b5e;background:var(--mint);border:0;width:max-content">拍照上传</div><div><div class="asset-title">状态层</div><div class="asset-meta">启动时轻提示,900ms 后淡出。</div></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel section">
|
||||||
|
<h2>启动动效节奏</h2>
|
||||||
|
<p>控制在 900ms 左右,不阻塞用户进入登录或首页。系统检测慢时只延长进度条,不重复播放动画。</p>
|
||||||
|
<div class="motion">
|
||||||
|
<div class="frame"><div class="mini-scene"></div><strong>0ms</strong><span>深色底和网格先出现,避免白屏。</span></div>
|
||||||
|
<div class="frame"><div class="mini-scene"></div><strong>160ms</strong><span>Logo scale 0.92 到 1,透明度进入。</span></div>
|
||||||
|
<div class="frame"><div class="mini-scene"></div><strong>320ms</strong><span>产品名和定位文案淡入。</span></div>
|
||||||
|
<div class="frame"><div class="mini-scene"></div><strong>520ms</strong><span>票据场景上移 8dp,进度条启动。</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel section">
|
||||||
|
<h2>Android 落地规格</h2>
|
||||||
|
<p>先走 Android 原生启动,再进入 Compose 首页。冷启动用系统 SplashScreen,业务素材用于第一个 Activity 的过渡页。</p>
|
||||||
|
<div class="spec-list">
|
||||||
|
<div class="spec"><strong>windowSplashScreenBackground</strong><span>#061416。系统启动页避免白屏,和业务过渡页底色一致。</span></div>
|
||||||
|
<div class="spec"><strong>windowSplashScreenAnimatedIcon</strong><span>只放 App 图标前景,不放产品文字,防止小屏裁切。</span></div>
|
||||||
|
<div class="spec"><strong>过渡页时长</strong><span>登录态检查完成即跳转;正常 600-900ms,异常最长 1800ms 后进入登录页。</span></div>
|
||||||
|
<div class="spec"><strong>Compose 拆分</strong><span>SplashBackground、BrandLockup、ExpenseScene、LoadingRail 四个组件。</span></div>
|
||||||
|
<div class="spec"><strong>无障碍</strong><span>启动页只设置应用名称 contentDescription,业务装饰图不参与朗读。</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
mobile/design/android-expense-splash-assets.png
Normal file
BIN
mobile/design/android-expense-splash-assets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 423 KiB |
BIN
mobile/design/yicai-splash-screen.png
Normal file
BIN
mobile/design/yicai-splash-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 462 KiB |
39
remove_bg.py
Normal file
39
remove_bg.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
def remove_white_bg(input_path, output_path, threshold=235):
|
||||||
|
img = Image.open(input_path).convert("RGBA")
|
||||||
|
data = img.getdata()
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
r, g, b, a = item
|
||||||
|
avg = (r + g + b) / 3.0
|
||||||
|
|
||||||
|
# If it's very close to white, make it transparent
|
||||||
|
if avg > threshold and min(r,g,b) > threshold - 10:
|
||||||
|
# Feathering the alpha channel
|
||||||
|
# 255 = fully transparent
|
||||||
|
# threshold = fully opaque
|
||||||
|
factor = (avg - threshold) / (255 - threshold)
|
||||||
|
alpha = int(255 * (1 - factor))
|
||||||
|
|
||||||
|
# Clamp alpha
|
||||||
|
alpha = max(0, min(255, alpha))
|
||||||
|
|
||||||
|
# We keep the pixel white to avoid dark fringes, but lower its opacity
|
||||||
|
new_data.append((255, 255, 255, alpha))
|
||||||
|
else:
|
||||||
|
new_data.append(item)
|
||||||
|
|
||||||
|
img.putdata(new_data)
|
||||||
|
|
||||||
|
# Optional: crop the image to its bounding box
|
||||||
|
bbox = img.getbbox()
|
||||||
|
if bbox:
|
||||||
|
img = img.crop(bbox)
|
||||||
|
|
||||||
|
img.save(output_path, "PNG")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
remove_white_bg(sys.argv[1], sys.argv[2])
|
||||||
45
remove_bg_fast.ps1
Normal file
45
remove_bg_fast.ps1
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
$code = @"
|
||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
public class ImageProcessor {
|
||||||
|
public static void RemoveWhiteBg(string inputFile, string outputFile, int threshold) {
|
||||||
|
Bitmap bmp = new Bitmap(inputFile);
|
||||||
|
Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
|
||||||
|
BitmapData bmpData = bmp.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
|
||||||
|
|
||||||
|
int bytes = Math.Abs(bmpData.Stride) * bmp.Height;
|
||||||
|
byte[] rgbValues = new byte[bytes];
|
||||||
|
Marshal.Copy(bmpData.Scan0, rgbValues, 0, bytes);
|
||||||
|
|
||||||
|
for (int i = 0; i < rgbValues.Length; i += 4) {
|
||||||
|
byte b = rgbValues[i];
|
||||||
|
byte g = rgbValues[i + 1];
|
||||||
|
byte r = rgbValues[i + 2];
|
||||||
|
byte a = rgbValues[i + 3];
|
||||||
|
|
||||||
|
float avg = (r + g + b) / 3f;
|
||||||
|
if (avg > threshold && r > threshold - 10 && g > threshold - 10 && b > threshold - 10) {
|
||||||
|
float factor = (avg - threshold) / (255f - threshold);
|
||||||
|
int alpha = (int)(255 * (1 - factor));
|
||||||
|
if (alpha < 0) alpha = 0;
|
||||||
|
if (alpha > 255) alpha = 255;
|
||||||
|
rgbValues[i] = 255;
|
||||||
|
rgbValues[i + 1] = 255;
|
||||||
|
rgbValues[i + 2] = 255;
|
||||||
|
rgbValues[i + 3] = (byte)alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.Copy(rgbValues, 0, bmpData.Scan0, bytes);
|
||||||
|
bmp.UnlockBits(bmpData);
|
||||||
|
bmp.Save(outputFile, ImageFormat.Png);
|
||||||
|
bmp.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
Add-Type -TypeDefinition $code -ReferencedAssemblies System.Drawing
|
||||||
|
[ImageProcessor]::RemoveWhiteBg("d:\Code\Project\X-Financial\web\src\assets\images\raw_documents.png", "d:\Code\Project\X-Financial\web\src\assets\images\hero-financial-decor.png", 235)
|
||||||
|
Write-Host "Done"
|
||||||
36
server/scripts/bootstrap_paddleocr_gpu.sh
Normal file
36
server/scripts/bootstrap_paddleocr_gpu.sh
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OCR_VENV_DIR="${OCR_VENV_DIR:-${ROOT_DIR}/.venv-ocr312}"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||||
|
PADDLEPADDLE_GPU_VERSION="${PADDLEPADDLE_GPU_VERSION:-3.3.0}"
|
||||||
|
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
|
||||||
|
PADDLE_GPU_INDEX_URL="${PADDLE_GPU_INDEX_URL:-https://www.paddlepaddle.org.cn/packages/stable/cu126/}"
|
||||||
|
|
||||||
|
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||||
|
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils
|
||||||
|
|
||||||
|
rm -rf "${OCR_VENV_DIR}"
|
||||||
|
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
|
||||||
|
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
|
||||||
|
"${OCR_VENV_DIR}/bin/pip" install \
|
||||||
|
"paddlepaddle-gpu==${PADDLEPADDLE_GPU_VERSION}" \
|
||||||
|
-i "${PADDLE_GPU_INDEX_URL}"
|
||||||
|
"${OCR_VENV_DIR}/bin/pip" install "paddleocr==${PADDLEOCR_VERSION}"
|
||||||
|
|
||||||
|
"${OCR_VENV_DIR}/bin/python" - <<'PY'
|
||||||
|
import paddle
|
||||||
|
|
||||||
|
print("PaddlePaddle:", paddle.__version__)
|
||||||
|
print("CUDA compiled:", paddle.is_compiled_with_cuda())
|
||||||
|
print("CUDA device count:", paddle.device.cuda.device_count())
|
||||||
|
paddle.utils.run_check()
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "PaddleOCR GPU runtime ${PADDLEOCR_VERSION} 已安装到 ${OCR_VENV_DIR}"
|
||||||
@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--lang", default="ch")
|
parser.add_argument("--lang", default="ch")
|
||||||
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
|
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
|
||||||
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
|
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
|
||||||
|
parser.add_argument("--device", default=os.environ.get("OCR_DEVICE", ""))
|
||||||
parser.add_argument("--enable-mkldnn", action="store_true")
|
parser.add_argument("--enable-mkldnn", action="store_true")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
@@ -100,16 +101,20 @@ def build_document(input_path: str, results: list[Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
ocr = PaddleOCR(
|
ocr_options = {
|
||||||
text_detection_model_name=args.text_detection_model,
|
"text_detection_model_name": args.text_detection_model,
|
||||||
text_recognition_model_name=args.text_recognition_model,
|
"text_recognition_model_name": args.text_recognition_model,
|
||||||
use_doc_orientation_classify=False,
|
"use_doc_orientation_classify": False,
|
||||||
use_doc_unwarping=False,
|
"use_doc_unwarping": False,
|
||||||
use_textline_orientation=False,
|
"use_textline_orientation": False,
|
||||||
lang=args.lang,
|
"lang": args.lang,
|
||||||
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
|
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
|
||||||
enable_mkldnn=args.enable_mkldnn,
|
"enable_mkldnn": args.enable_mkldnn,
|
||||||
)
|
}
|
||||||
|
configured_device = str(args.device or "").strip()
|
||||||
|
if configured_device:
|
||||||
|
ocr_options["device"] = configured_device
|
||||||
|
ocr = PaddleOCR(**ocr_options)
|
||||||
|
|
||||||
documents = []
|
documents = []
|
||||||
for input_path in args.inputs:
|
for input_path in args.inputs:
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class Settings(BaseSettings):
|
|||||||
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
||||||
storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR")
|
storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR")
|
||||||
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
|
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
|
||||||
|
ocr_device: str = Field(default="", alias="OCR_DEVICE")
|
||||||
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
|
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
|
||||||
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
|
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
|
||||||
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
|
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
|
||||||
|
|||||||
@@ -68,8 +68,16 @@ class ExpenseClaim(Base):
|
|||||||
return None
|
return None
|
||||||
if self.employee.manager is not None and self.employee.manager.name:
|
if self.employee.manager is not None and self.employee.manager.name:
|
||||||
return str(self.employee.manager.name).strip() or None
|
return str(self.employee.manager.name).strip() or None
|
||||||
|
if self.employee.organization_unit is not None and self.employee.organization_unit.manager_name:
|
||||||
|
return str(self.employee.organization_unit.manager_name).strip() or None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finance_owner_name(self) -> str | None:
|
||||||
|
if self.employee is None or not self.employee.finance_owner_name:
|
||||||
|
return None
|
||||||
|
return str(self.employee.finance_owner_name).strip() or None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role_labels(self) -> list[str]:
|
def role_labels(self) -> list[str]:
|
||||||
if self.employee is None or not self.employee.roles:
|
if self.employee is None or not self.employee.roles:
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from datetime import date, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
|
||||||
|
|
||||||
|
|
||||||
class ReimbursementCreate(BaseModel):
|
class ReimbursementCreate(BaseModel):
|
||||||
@@ -147,6 +149,8 @@ class ExpenseClaimRead(BaseModel):
|
|||||||
employee_position: str | None = None
|
employee_position: str | None = None
|
||||||
employee_grade: str | None = None
|
employee_grade: str | None = None
|
||||||
manager_name: str | None = None
|
manager_name: str | None = None
|
||||||
|
finance_owner_name: str | None = None
|
||||||
|
finance_approver_name: str | None = None
|
||||||
budget_approver_name: str | None = None
|
budget_approver_name: str | None = None
|
||||||
budget_approver_grade: str | None = None
|
budget_approver_grade: str | None = None
|
||||||
budget_approver_role_code: str | None = None
|
budget_approver_role_code: str | None = None
|
||||||
@@ -167,6 +171,13 @@ class ExpenseClaimRead(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
|
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("risk_flags_json", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def dedupe_budget_risk_flags_for_read(cls, value: Any) -> list[Any]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return dedupe_budget_risk_flags(value)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimActionResponse(BaseModel):
|
class ExpenseClaimActionResponse(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|||||||
@@ -250,6 +250,45 @@ class ExpenseClaimAccessPolicy:
|
|||||||
return role_code
|
return role_code
|
||||||
return BUDGET_MONITOR_ROLE_CODE
|
return BUDGET_MONITOR_ROLE_CODE
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_claim_finance_owner_name(claim: ExpenseClaim) -> str:
|
||||||
|
employee = claim.employee
|
||||||
|
if employee is not None and employee.finance_owner_name:
|
||||||
|
return str(employee.finance_owner_name).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def resolve_finance_approver(self, claim: ExpenseClaim) -> Employee | None:
|
||||||
|
claim_employee_id = str(claim.employee_id or "").strip()
|
||||||
|
base_stmt = (
|
||||||
|
select(Employee)
|
||||||
|
.options(selectinload(Employee.roles))
|
||||||
|
.where(Employee.roles.any(Role.role_code == "finance"))
|
||||||
|
)
|
||||||
|
if claim_employee_id:
|
||||||
|
base_stmt = base_stmt.where(Employee.id != claim_employee_id)
|
||||||
|
|
||||||
|
finance_owner_name = self.resolve_claim_finance_owner_name(claim)
|
||||||
|
if finance_owner_name:
|
||||||
|
named_finance = self.db.scalar(
|
||||||
|
base_stmt
|
||||||
|
.where(Employee.name == finance_owner_name)
|
||||||
|
.order_by(Employee.name.asc(), Employee.employee_no.asc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if named_finance is not None:
|
||||||
|
return named_finance
|
||||||
|
|
||||||
|
owner_matched_finance = self.db.scalar(
|
||||||
|
base_stmt
|
||||||
|
.where(func.lower(func.coalesce(Employee.finance_owner_name, "")) == finance_owner_name.lower())
|
||||||
|
.order_by(Employee.name.asc(), Employee.employee_no.asc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if owner_matched_finance is not None:
|
||||||
|
return owner_matched_finance
|
||||||
|
|
||||||
|
return self.db.scalar(base_stmt.order_by(Employee.name.asc(), Employee.employee_no.asc()).limit(1))
|
||||||
|
|
||||||
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||||
if claim is None:
|
if claim is None:
|
||||||
return None
|
return None
|
||||||
@@ -269,9 +308,25 @@ class ExpenseClaimAccessPolicy:
|
|||||||
)
|
)
|
||||||
return claim
|
return claim
|
||||||
|
|
||||||
|
def attach_finance_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||||
|
if claim is None:
|
||||||
|
return None
|
||||||
|
if str(claim.approval_stage or "").strip() != FINANCE_APPROVAL_STAGE:
|
||||||
|
return claim
|
||||||
|
|
||||||
|
finance_approver = self.resolve_finance_approver(claim)
|
||||||
|
if finance_approver is not None and finance_approver.name:
|
||||||
|
setattr(claim, "finance_approver_name", str(finance_approver.name).strip())
|
||||||
|
return claim
|
||||||
|
|
||||||
|
def attach_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||||
|
self.attach_budget_approval_snapshot(claim)
|
||||||
|
self.attach_finance_approval_snapshot(claim)
|
||||||
|
return claim
|
||||||
|
|
||||||
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
self.attach_budget_approval_snapshot(claim)
|
self.attach_approval_snapshot(claim)
|
||||||
return claims
|
return claims
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -647,6 +702,11 @@ class ExpenseClaimAccessPolicy:
|
|||||||
*,
|
*,
|
||||||
include_approval_scope: bool = False,
|
include_approval_scope: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
if current_user.is_admin:
|
||||||
|
if include_approval_scope:
|
||||||
|
return stmt
|
||||||
|
return stmt.where(~self.build_archived_claim_condition())
|
||||||
|
|
||||||
conditions = self.build_personal_claim_conditions(current_user)
|
conditions = self.build_personal_claim_conditions(current_user)
|
||||||
role_codes = self.normalize_role_codes(current_user)
|
role_codes = self.normalize_role_codes(current_user)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.services.expense_claim_risk_stage import (
|
|||||||
|
|
||||||
|
|
||||||
class ExpenseClaimApprovalRoutingMixin:
|
class ExpenseClaimApprovalRoutingMixin:
|
||||||
|
_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = Decimal("90.00")
|
||||||
_BUDGET_REVIEW_RATINGS = {"block"}
|
_BUDGET_REVIEW_RATINGS = {"block"}
|
||||||
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
|
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
|
||||||
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
|
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
|
||||||
@@ -63,7 +64,11 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
|
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
|
||||||
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
|
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
|
||||||
budget_reasons = self._collect_budget_route_reasons(budget_result)
|
budget_reasons = (
|
||||||
|
self._collect_application_budget_route_reasons(budget_result)
|
||||||
|
if is_application_claim
|
||||||
|
else self._collect_budget_route_reasons(budget_result)
|
||||||
|
)
|
||||||
current_risk_reasons = self._collect_current_route_risk_reasons(
|
current_risk_reasons = self._collect_current_route_risk_reasons(
|
||||||
claim.risk_flags_json,
|
claim.risk_flags_json,
|
||||||
business_stage=business_stage,
|
business_stage=business_stage,
|
||||||
@@ -75,7 +80,9 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
reasons = self._dedupe_reasons(
|
reasons = self._dedupe_reasons(
|
||||||
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
|
budget_reasons
|
||||||
|
if is_application_claim
|
||||||
|
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
|
||||||
)
|
)
|
||||||
requires_budget_review = bool(reasons)
|
requires_budget_review = bool(reasons)
|
||||||
route = (
|
route = (
|
||||||
@@ -86,6 +93,13 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
else "finance"
|
else "finance"
|
||||||
)
|
)
|
||||||
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
|
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
|
||||||
|
if is_application_claim:
|
||||||
|
message = (
|
||||||
|
"系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。"
|
||||||
|
if requires_budget_review
|
||||||
|
else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
message = (
|
message = (
|
||||||
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
|
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
|
||||||
if requires_budget_review
|
if requires_budget_review
|
||||||
@@ -136,6 +150,20 @@ class ExpenseClaimApprovalRoutingMixin:
|
|||||||
reasons.append(f"预计超预算 {over_budget_amount} 元")
|
reasons.append(f"预计超预算 {over_budget_amount} 元")
|
||||||
return self._dedupe_reasons(reasons)
|
return self._dedupe_reasons(reasons)
|
||||||
|
|
||||||
|
def _collect_application_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
|
||||||
|
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||||
|
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
|
||||||
|
if over_budget_amount > Decimal("0.00"):
|
||||||
|
return [f"预计超预算 {over_budget_amount} 元"]
|
||||||
|
|
||||||
|
after_usage_rate = self._decimal(metrics.get("after_usage_rate"))
|
||||||
|
claim_amount_ratio = self._decimal(metrics.get("claim_amount_ratio"))
|
||||||
|
budget_usage_rate = max(after_usage_rate, claim_amount_ratio)
|
||||||
|
if budget_usage_rate >= self._APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD:
|
||||||
|
return [f"审批后预算占用达到 {budget_usage_rate}%,触发 90% 预算复核线"]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
def _collect_current_route_risk_reasons(
|
def _collect_current_route_risk_reasons(
|
||||||
self,
|
self,
|
||||||
risk_flags: list[Any] | None,
|
risk_flags: list[Any] | None,
|
||||||
|
|||||||
@@ -352,9 +352,15 @@ class ExpenseClaimAttachmentOperationsMixin:
|
|||||||
self._ensure_draft_claim(claim)
|
self._ensure_draft_claim(claim)
|
||||||
self._ensure_mutable_claim_item(item)
|
self._ensure_mutable_claim_item(item)
|
||||||
before_json = self._serialize_claim(claim)
|
before_json = self._serialize_claim(claim)
|
||||||
|
previous_invoice_id = str(item.invoice_id or "").strip()
|
||||||
previous_name = self._attachment_presentation.resolve_display_name(item.invoice_id)
|
previous_name = self._attachment_presentation.resolve_display_name(item.invoice_id)
|
||||||
self._attachment_storage.delete_item_files(item)
|
self._attachment_storage.delete_item_files(item)
|
||||||
item.invoice_id = None
|
item.invoice_id = None
|
||||||
|
claim.risk_flags_json = self._remove_deleted_attachment_risk_flags(
|
||||||
|
claim.risk_flags_json,
|
||||||
|
item_id=item.id,
|
||||||
|
invoice_id=previous_invoice_id,
|
||||||
|
)
|
||||||
|
|
||||||
self._sync_claim_from_items(claim)
|
self._sync_claim_from_items(claim)
|
||||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||||
@@ -379,6 +385,36 @@ class ExpenseClaimAttachmentOperationsMixin:
|
|||||||
"attachment": None,
|
"attachment": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_deleted_attachment_risk_flags(
|
||||||
|
risk_flags: Any,
|
||||||
|
*,
|
||||||
|
item_id: str | None,
|
||||||
|
invoice_id: str | None,
|
||||||
|
) -> list[Any]:
|
||||||
|
normalized_item_id = str(item_id or "").strip()
|
||||||
|
normalized_invoice_id = str(invoice_id or "").strip()
|
||||||
|
cleaned_flags: list[Any] = []
|
||||||
|
for flag in list(risk_flags or []):
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
cleaned_flags.append(flag)
|
||||||
|
continue
|
||||||
|
|
||||||
|
source = str(flag.get("source") or "").strip()
|
||||||
|
if source != "attachment_analysis":
|
||||||
|
cleaned_flags.append(flag)
|
||||||
|
continue
|
||||||
|
|
||||||
|
flag_item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
|
||||||
|
flag_invoice_id = str(flag.get("invoice_id") or flag.get("invoiceId") or "").strip()
|
||||||
|
matches_deleted_item = bool(normalized_item_id and flag_item_id == normalized_item_id)
|
||||||
|
matches_deleted_invoice = bool(normalized_invoice_id and flag_invoice_id == normalized_invoice_id)
|
||||||
|
if matches_deleted_item or matches_deleted_invoice:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleaned_flags.append(flag)
|
||||||
|
return cleaned_flags
|
||||||
|
|
||||||
def _get_claim_item_or_raise(
|
def _get_claim_item_or_raise(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
|
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
|
||||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ class ExpenseClaimBudgetFlowMixin:
|
|||||||
else flag
|
else flag
|
||||||
for flag in next_flags
|
for flag in next_flags
|
||||||
]
|
]
|
||||||
return [*list(risk_flags or []), *enriched_flags]
|
return dedupe_budget_risk_flags([*list(risk_flags or []), *enriched_flags])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:
|
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:
|
||||||
|
|||||||
47
server/src/app/services/expense_claim_budget_risk_flags.py
Normal file
47
server/src/app/services/expense_claim_budget_risk_flags.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_DEDUPED_BUDGET_RISK_EVENT_TYPES = {
|
||||||
|
"budget_frozen",
|
||||||
|
"budget_insufficient",
|
||||||
|
"budget_missing",
|
||||||
|
"budget_warning",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_budget_risk_flags(flags: list[Any] | None) -> list[Any]:
|
||||||
|
"""Collapse repeated budget risk warnings while preserving non-risk audit flags."""
|
||||||
|
|
||||||
|
deduped: list[Any] = []
|
||||||
|
key_to_index: dict[tuple[str, str, str, str], int] = {}
|
||||||
|
|
||||||
|
for flag in list(flags or []):
|
||||||
|
key = budget_risk_flag_key(flag)
|
||||||
|
if key is None:
|
||||||
|
deduped.append(flag)
|
||||||
|
continue
|
||||||
|
existing_index = key_to_index.get(key)
|
||||||
|
if existing_index is None:
|
||||||
|
key_to_index[key] = len(deduped)
|
||||||
|
deduped.append(flag)
|
||||||
|
continue
|
||||||
|
deduped[existing_index] = flag
|
||||||
|
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def budget_risk_flag_key(flag: Any) -> tuple[str, str, str, str] | None:
|
||||||
|
if not isinstance(flag, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
source = str(flag.get("source") or "").strip()
|
||||||
|
event_type = str(flag.get("event_type") or flag.get("eventType") or "").strip()
|
||||||
|
if source != "budget_control" or event_type not in _DEDUPED_BUDGET_RISK_EVENT_TYPES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
allocation_key = str(flag.get("allocation_id") or flag.get("allocationId") or "").strip()
|
||||||
|
budget_no = str(flag.get("budget_no") or flag.get("budgetNo") or "").strip()
|
||||||
|
subject_code = str(flag.get("subject_code") or flag.get("subjectCode") or "").strip()
|
||||||
|
return (source, event_type, allocation_key or budget_no, subject_code)
|
||||||
@@ -130,7 +130,10 @@ class ExpenseClaimRiskReviewMixin(
|
|||||||
attention_reasons.extend(scene_policy_review["blocking_reasons"])
|
attention_reasons.extend(scene_policy_review["blocking_reasons"])
|
||||||
review_flags.extend(scene_policy_review["flags"])
|
review_flags.extend(scene_policy_review["flags"])
|
||||||
|
|
||||||
platform_risk_review = self.evaluate_platform_risk_rules(claim)
|
platform_risk_review = self.evaluate_platform_risk_rules(
|
||||||
|
claim,
|
||||||
|
business_stage="reimbursement",
|
||||||
|
)
|
||||||
attention_reasons.extend(platform_risk_review["blocking_reasons"])
|
attention_reasons.extend(platform_risk_review["blocking_reasons"])
|
||||||
platform_risk_flags = list(platform_risk_review["flags"])
|
platform_risk_flags = list(platform_risk_review["flags"])
|
||||||
review_flags.extend(platform_risk_flags)
|
review_flags.extend(platform_risk_flags)
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ class ExpenseClaimService(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(ExpenseClaim.items),
|
selectinload(ExpenseClaim.items),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||||
|
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||||
)
|
)
|
||||||
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
||||||
@@ -217,6 +218,7 @@ class ExpenseClaimService(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(ExpenseClaim.items),
|
selectinload(ExpenseClaim.items),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||||
|
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||||
)
|
)
|
||||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||||
@@ -230,6 +232,7 @@ class ExpenseClaimService(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(ExpenseClaim.items),
|
selectinload(ExpenseClaim.items),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||||
|
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||||
)
|
)
|
||||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||||
@@ -243,12 +246,13 @@ class ExpenseClaimService(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(ExpenseClaim.items),
|
selectinload(ExpenseClaim.items),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||||
|
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||||
)
|
)
|
||||||
.where(ExpenseClaim.id == claim_id)
|
.where(ExpenseClaim.id == claim_id)
|
||||||
)
|
)
|
||||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||||
return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt))
|
return self._access_policy.attach_approval_snapshot(self.db.scalar(stmt))
|
||||||
|
|
||||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||||
if claim is None:
|
if claim is None:
|
||||||
@@ -1019,5 +1023,3 @@ class ExpenseClaimService(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ class OcrService:
|
|||||||
return "|".join(
|
return "|".join(
|
||||||
[
|
[
|
||||||
self.settings.ocr_language,
|
self.settings.ocr_language,
|
||||||
|
self.settings.ocr_device,
|
||||||
self.settings.ocr_text_detection_model,
|
self.settings.ocr_text_detection_model,
|
||||||
self.settings.ocr_text_recognition_model,
|
self.settings.ocr_text_recognition_model,
|
||||||
digest,
|
digest,
|
||||||
@@ -333,6 +334,9 @@ class OcrService:
|
|||||||
"--text-recognition-model",
|
"--text-recognition-model",
|
||||||
self.settings.ocr_text_recognition_model,
|
self.settings.ocr_text_recognition_model,
|
||||||
]
|
]
|
||||||
|
configured_device = str(self.settings.ocr_device or "").strip()
|
||||||
|
if configured_device:
|
||||||
|
command.extend(["--device", configured_device])
|
||||||
for path in input_paths:
|
for path in input_paths:
|
||||||
command.extend(["--input", str(path)])
|
command.extend(["--input", str(path)])
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
|
|||||||
"employee_no": ("employeeNo",),
|
"employee_no": ("employeeNo",),
|
||||||
"employee_position": ("position", "employeePosition"),
|
"employee_position": ("position", "employeePosition"),
|
||||||
"manager_name": ("managerName", "direct_manager_name", "directManagerName"),
|
"manager_name": ("managerName", "direct_manager_name", "directManagerName"),
|
||||||
|
"finance_owner_name": ("financeOwnerName",),
|
||||||
|
"finance_approver_name": ("financeApproverName",),
|
||||||
}
|
}
|
||||||
|
|
||||||
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
|
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
|
||||||
@@ -66,7 +68,6 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
|
|||||||
"control_action",
|
"control_action",
|
||||||
"employee_location",
|
"employee_location",
|
||||||
"employee_risk_profile",
|
"employee_risk_profile",
|
||||||
"finance_owner_name",
|
|
||||||
"document_id",
|
"document_id",
|
||||||
"application_claim_id",
|
"application_claim_id",
|
||||||
"application_claim_no",
|
"application_claim_no",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -114,6 +114,7 @@ class SystemDashboardService:
|
|||||||
succeeded_runs = sum(1 for run in runs if self._is_success(run.status))
|
succeeded_runs = sum(1 for run in runs if self._is_success(run.status))
|
||||||
failed_runs = sum(1 for run in runs if self._is_failed(run.status))
|
failed_runs = sum(1 for run in runs if self._is_failed(run.status))
|
||||||
active_sessions = [item for item in sessions if str(item.status or "") == "active"]
|
active_sessions = [item for item in sessions if str(item.status or "") == "active"]
|
||||||
|
online_user_count = len(self._unique_session_identities(active_sessions))
|
||||||
|
|
||||||
return SystemDashboardRead(
|
return SystemDashboardRead(
|
||||||
window_days=window_days,
|
window_days=window_days,
|
||||||
@@ -122,7 +123,7 @@ class SystemDashboardService:
|
|||||||
totals={
|
totals={
|
||||||
"toolCalls": len(tool_calls),
|
"toolCalls": len(tool_calls),
|
||||||
"modelTokens": total_tokens,
|
"modelTokens": total_tokens,
|
||||||
"onlineUsers": len(active_sessions),
|
"onlineUsers": online_user_count,
|
||||||
"avgOnlineMinutes": self._average_session_minutes(sessions, now),
|
"avgOnlineMinutes": self._average_session_minutes(sessions, now),
|
||||||
"executionSuccessRate": self._percent(succeeded_runs, len(runs)),
|
"executionSuccessRate": self._percent(succeeded_runs, len(runs)),
|
||||||
"positiveFeedback": positive_feedback,
|
"positiveFeedback": positive_feedback,
|
||||||
@@ -132,7 +133,7 @@ class SystemDashboardService:
|
|||||||
"modelTokensChange": self._change_percent(total_tokens, previous_tokens),
|
"modelTokensChange": self._change_percent(total_tokens, previous_tokens),
|
||||||
},
|
},
|
||||||
agent_daily_ratio=self._agent_daily_ratio(labels, tool_calls),
|
agent_daily_ratio=self._agent_daily_ratio(labels, tool_calls),
|
||||||
login_wave=self._login_wave(sessions),
|
login_wave=self._login_wave(sessions, start, now),
|
||||||
token_daily_wave=self._token_daily_wave(labels, token_records),
|
token_daily_wave=self._token_daily_wave(labels, token_records),
|
||||||
user_token_usage=self._user_token_usage(token_records, user_names),
|
user_token_usage=self._user_token_usage(token_records, user_names),
|
||||||
accuracy_comparison=self._accuracy_comparison(tool_calls),
|
accuracy_comparison=self._accuracy_comparison(tool_calls),
|
||||||
@@ -215,7 +216,14 @@ class SystemDashboardService:
|
|||||||
def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]:
|
def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(UserSessionMetric)
|
select(UserSessionMetric)
|
||||||
.where(UserSessionMetric.login_at >= start)
|
.where(
|
||||||
|
or_(
|
||||||
|
UserSessionMetric.login_at >= start,
|
||||||
|
UserSessionMetric.logout_at >= start,
|
||||||
|
UserSessionMetric.last_activity_at >= start,
|
||||||
|
UserSessionMetric.status == "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(UserSessionMetric.login_at.asc())
|
.order_by(UserSessionMetric.login_at.asc())
|
||||||
)
|
)
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
@@ -258,19 +266,43 @@ class SystemDashboardService:
|
|||||||
"series": ratio_series,
|
"series": ratio_series,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _login_wave(self, sessions: list[UserSessionMetric]) -> dict[str, Any]:
|
def _login_wave(
|
||||||
labels = [f"{hour:02d}:00" for hour in range(8, 21)]
|
self,
|
||||||
login_users = [0 for _ in labels]
|
sessions: list[UserSessionMetric],
|
||||||
|
start: datetime,
|
||||||
|
now: datetime,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
labels = [f"{hour:02d}:00" for hour in range(24)]
|
||||||
|
online_users = [set[str]() for _ in labels]
|
||||||
interactions = [0 for _ in labels]
|
interactions = [0 for _ in labels]
|
||||||
index = {label: idx for idx, label in enumerate(labels)}
|
index = {label: idx for idx, label in enumerate(labels)}
|
||||||
|
window_start = self._as_utc(start)
|
||||||
|
window_end = self._as_utc(now)
|
||||||
|
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
hour = self._as_utc(session.login_at).hour
|
login_at = self._as_utc(session.login_at)
|
||||||
label = f"{hour:02d}:00"
|
end_at = self._session_end_at(session, now)
|
||||||
if label not in index:
|
if end_at < window_start or login_at > window_end:
|
||||||
continue
|
continue
|
||||||
login_users[index[label]] += 1
|
|
||||||
interactions[index[label]] += max(0, int(session.activity_event_count or 0))
|
identity = self._session_identity(session)
|
||||||
return {"labels": labels, "loginUsers": login_users, "interactions": interactions}
|
overlap_start = max(login_at, window_start)
|
||||||
|
overlap_end = min(end_at, window_end)
|
||||||
|
cursor = overlap_start.replace(minute=0, second=0, microsecond=0)
|
||||||
|
while cursor <= overlap_end:
|
||||||
|
label = f"{cursor.hour:02d}:00"
|
||||||
|
online_users[index[label]].add(identity)
|
||||||
|
cursor += timedelta(hours=1)
|
||||||
|
|
||||||
|
login_label = f"{login_at.hour:02d}:00"
|
||||||
|
if login_at >= window_start and login_label in index:
|
||||||
|
interactions[index[login_label]] += max(0, int(session.activity_event_count or 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"labels": labels,
|
||||||
|
"loginUsers": [len(items) for items in online_users],
|
||||||
|
"interactions": interactions,
|
||||||
|
}
|
||||||
|
|
||||||
def _token_daily_wave(self, labels: list[str], records: list[dict[str, Any]]) -> dict[str, Any]:
|
def _token_daily_wave(self, labels: list[str], records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
input_tokens = [0 for _ in labels]
|
input_tokens = [0 for _ in labels]
|
||||||
@@ -547,12 +579,28 @@ class SystemDashboardService:
|
|||||||
if int(session.duration_ms or 0) > 0:
|
if int(session.duration_ms or 0) > 0:
|
||||||
return max(0, int(session.duration_ms or 0))
|
return max(0, int(session.duration_ms or 0))
|
||||||
login_at = self._as_utc(session.login_at)
|
login_at = self._as_utc(session.login_at)
|
||||||
end_at = self._as_utc(session.logout_at or session.last_activity_at or now)
|
end_at = self._session_end_at(session, now)
|
||||||
try:
|
try:
|
||||||
return max(0, min(int((end_at - login_at).total_seconds() * 1000), 24 * 60 * 60 * 1000))
|
return max(0, min(int((end_at - login_at).total_seconds() * 1000), 24 * 60 * 60 * 1000))
|
||||||
except TypeError:
|
except TypeError:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def _session_end_at(self, session: UserSessionMetric, now: datetime) -> datetime:
|
||||||
|
if str(session.status or "").strip().lower() == "active":
|
||||||
|
return self._as_utc(now)
|
||||||
|
return self._as_utc(session.logout_at or session.last_activity_at or session.login_at or now)
|
||||||
|
|
||||||
|
def _unique_session_identities(self, sessions: list[UserSessionMetric]) -> set[str]:
|
||||||
|
return {self._session_identity(item) for item in sessions}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _session_identity(session: UserSessionMetric) -> str:
|
||||||
|
for value in (session.username, session.email, session.employee_no, session.display_name):
|
||||||
|
normalized = str(value or "").strip().lower()
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
return str(session.session_id or "").strip().lower()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _date_labels(start_date: date, days: int) -> list[str]:
|
def _date_labels(start_date: date, days: int) -> list[str]:
|
||||||
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]
|
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]
|
||||||
|
|||||||
@@ -234,6 +234,124 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
department, manager, _budget_manager, employee = _seed_people(db, suffix="OVER-90-APP")
|
||||||
|
_seed_budget_allocation(
|
||||||
|
db,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
amount=Decimal("10000.00"),
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260530-OVER-90",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户现场支持",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("9500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
routed = ExpenseClaimService(db).approve_claim(
|
||||||
|
claim.id,
|
||||||
|
CurrentUserContext(
|
||||||
|
username=manager.email,
|
||||||
|
name=manager.name,
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
opinion="业务必要,同意申请。",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert routed is not None
|
||||||
|
assert routed.status == "submitted"
|
||||||
|
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||||
|
assert any(
|
||||||
|
isinstance(flag, dict)
|
||||||
|
and flag.get("source") == "approval_routing"
|
||||||
|
and flag.get("requires_budget_review") is True
|
||||||
|
and flag.get("route") == "budget_manager"
|
||||||
|
and any("90%" in item or "90" in item for item in flag.get("reasons", []))
|
||||||
|
for flag in routed.risk_flags_json
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
|
||||||
|
_seed_budget_allocation(
|
||||||
|
db,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
amount=Decimal("10000.00"),
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260530-RISK-UNDER-90",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="客户现场支持",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||||
|
risk_flags_json=[
|
||||||
|
{
|
||||||
|
"source": "submission_review",
|
||||||
|
"severity": "high",
|
||||||
|
"label": "申请信息风险",
|
||||||
|
"message": "申请事由需要领导关注。",
|
||||||
|
"business_stage": "expense_application",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
approved = ExpenseClaimService(db).approve_claim(
|
||||||
|
claim.id,
|
||||||
|
CurrentUserContext(
|
||||||
|
username=manager.email,
|
||||||
|
name=manager.name,
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
opinion="业务必要,同意申请。",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert approved is not None
|
||||||
|
assert approved.status == "approved"
|
||||||
|
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||||
|
route_flag = [
|
||||||
|
flag
|
||||||
|
for flag in approved.risk_flags_json
|
||||||
|
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
||||||
|
][0]
|
||||||
|
assert route_flag["requires_budget_review"] is False
|
||||||
|
assert route_flag["route"] == "approval_done"
|
||||||
|
assert route_flag["current_risk_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE")
|
department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from app.schemas.reimbursement import (
|
|||||||
)
|
)
|
||||||
from app.services.agent_conversations import AgentConversationService
|
from app.services.agent_conversations import AgentConversationService
|
||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
|
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.expense_claim_workflow_constants import (
|
from app.services.expense_claim_workflow_constants import (
|
||||||
@@ -119,6 +120,42 @@ def build_session() -> Session:
|
|||||||
return session_factory()
|
return session_factory()
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_budget_flags_replaces_duplicate_budget_warning() -> None:
|
||||||
|
base_warning = {
|
||||||
|
"source": "budget_control",
|
||||||
|
"event_type": "budget_warning",
|
||||||
|
"severity": "medium",
|
||||||
|
"label": "预算接近预警线",
|
||||||
|
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 98.00%,已达到预警线 80.00%。",
|
||||||
|
"budget_no": "SIM-BUD-2026-R0048",
|
||||||
|
"allocation_id": "allocation-0048",
|
||||||
|
"subject_code": "travel",
|
||||||
|
"created_at": "2026-06-03T10:00:00+00:00",
|
||||||
|
}
|
||||||
|
latest_warning = {
|
||||||
|
**base_warning,
|
||||||
|
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%。",
|
||||||
|
"created_at": "2026-06-03T10:01:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
flags = ExpenseClaimBudgetFlowMixin._append_budget_flags(
|
||||||
|
[base_warning],
|
||||||
|
latest_warning,
|
||||||
|
business_stage="reimbursement",
|
||||||
|
)
|
||||||
|
|
||||||
|
warnings = [
|
||||||
|
flag
|
||||||
|
for flag in flags
|
||||||
|
if isinstance(flag, dict)
|
||||||
|
and flag.get("source") == "budget_control"
|
||||||
|
and flag.get("event_type") == "budget_warning"
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "99.27%" in warnings[0]["message"]
|
||||||
|
assert warnings[0]["business_stage"] == "reimbursement"
|
||||||
|
|
||||||
|
|
||||||
def _count_claims(db: Session) -> int:
|
def _count_claims(db: Session) -> int:
|
||||||
return int(db.query(ExpenseClaim).count())
|
return int(db.query(ExpenseClaim).count())
|
||||||
|
|
||||||
@@ -2360,6 +2397,112 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="emp-hotel-risk@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_recognize(
|
||||||
|
self,
|
||||||
|
files: list[tuple[str, bytes, str | None]],
|
||||||
|
) -> OcrRecognizeBatchRead:
|
||||||
|
return OcrRecognizeBatchRead(
|
||||||
|
total_file_count=1,
|
||||||
|
success_count=1,
|
||||||
|
documents=[
|
||||||
|
OcrRecognizeDocumentRead(
|
||||||
|
filename="hotel-risk.png",
|
||||||
|
media_type="image/png",
|
||||||
|
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
|
||||||
|
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
|
||||||
|
avg_score=0.98,
|
||||||
|
line_count=1,
|
||||||
|
page_count=1,
|
||||||
|
document_type="hotel_invoice",
|
||||||
|
document_type_label="酒店住宿票据",
|
||||||
|
scene_code="hotel",
|
||||||
|
scene_label="住宿票据",
|
||||||
|
document_fields=[
|
||||||
|
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
|
||||||
|
{"key": "amount", "label": "金额", "value": "800元"},
|
||||||
|
{"key": "date", "label": "日期", "value": "2026-05-13"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||||
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E7402",
|
||||||
|
name="张三",
|
||||||
|
email="emp-hotel-risk@example.com",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
claim = build_claim(expense_type="travel", location="北京")
|
||||||
|
claim.employee = employee
|
||||||
|
claim.employee_id = employee.id
|
||||||
|
claim.reason = "北京客户现场出差"
|
||||||
|
claim.invoice_count = 0
|
||||||
|
claim.items[0].item_type = "hotel"
|
||||||
|
claim.items[0].item_reason = "北京住宿"
|
||||||
|
claim.items[0].item_location = "北京"
|
||||||
|
claim.items[0].item_amount = Decimal("0.00")
|
||||||
|
claim.items[0].invoice_id = None
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
upload_payload = service.upload_claim_item_attachment(
|
||||||
|
claim_id=claim.id,
|
||||||
|
item_id=claim.items[0].id,
|
||||||
|
filename="hotel-risk.png",
|
||||||
|
content=b"fake-image-bytes",
|
||||||
|
media_type="image/png",
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert upload_payload is not None
|
||||||
|
assert any(
|
||||||
|
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||||
|
for flag in upload_payload["claim_risk_flags"]
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_payload = service.delete_claim_item_attachment(
|
||||||
|
claim_id=claim.id,
|
||||||
|
item_id=claim.items[0].id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert delete_payload is not None
|
||||||
|
assert delete_payload["invoice_id"] is None
|
||||||
|
assert not any(
|
||||||
|
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||||
|
for flag in delete_payload["claim_risk_flags"]
|
||||||
|
)
|
||||||
|
assert not any(
|
||||||
|
"高风险附件" in str(flag.get("message") or "")
|
||||||
|
for flag in delete_payload["claim_risk_flags"]
|
||||||
|
if isinstance(flag, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.refresh(claim)
|
||||||
|
assert claim.invoice_count == 0
|
||||||
|
assert claim.items[0].invoice_id is None
|
||||||
|
assert not any(
|
||||||
|
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||||
|
for flag in list(claim.risk_flags_json or [])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
|
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
claim = build_claim(expense_type="travel", location="上海")
|
claim = build_claim(expense_type="travel", location="上海")
|
||||||
@@ -3741,6 +3884,101 @@ def test_list_claims_returns_company_reimbursements_for_finance_document_center(
|
|||||||
assert "EXP-FIN-COMPANY-PAID" in archived_nos
|
assert "EXP-FIN-COMPANY-PAID" in archived_nos
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_claims_returns_all_active_documents_for_admin_document_center() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="admin",
|
||||||
|
name="admin",
|
||||||
|
role_codes=["admin"],
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="AP-ADMIN-DRAFT",
|
||||||
|
employee_name="Applicant A",
|
||||||
|
department_name="Tech",
|
||||||
|
project_code="PRJ-APP",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="Travel application draft",
|
||||||
|
location="Shanghai",
|
||||||
|
amount=Decimal("1200.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=None,
|
||||||
|
status="draft",
|
||||||
|
approval_stage="draft",
|
||||||
|
risk_flags_json=[],
|
||||||
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="AP-ADMIN-LINKING",
|
||||||
|
employee_name="Applicant B",
|
||||||
|
department_name="Tech",
|
||||||
|
project_code="PRJ-APP",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="Travel application approved",
|
||||||
|
location="Beijing",
|
||||||
|
amount=Decimal("2200.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||||
|
status="approved",
|
||||||
|
approval_stage=APPLICATION_LINK_STATUS_STAGE,
|
||||||
|
risk_flags_json=[],
|
||||||
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="EXP-ADMIN-DRAFT",
|
||||||
|
employee_name="Applicant C",
|
||||||
|
department_name="Finance",
|
||||||
|
project_code="PRJ-EXP",
|
||||||
|
expense_type="office",
|
||||||
|
reason="Office draft",
|
||||||
|
location="Hangzhou",
|
||||||
|
amount=Decimal("300.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 13, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=None,
|
||||||
|
status="draft",
|
||||||
|
approval_stage="draft",
|
||||||
|
risk_flags_json=[],
|
||||||
|
),
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="EXP-ADMIN-ARCHIVED",
|
||||||
|
employee_name="Applicant D",
|
||||||
|
department_name="Finance",
|
||||||
|
project_code="PRJ-EXP",
|
||||||
|
expense_type="office",
|
||||||
|
reason="Archived reimbursement",
|
||||||
|
location="Hangzhou",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 14, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 14, 10, 0, tzinfo=UTC),
|
||||||
|
status="paid",
|
||||||
|
approval_stage="payment",
|
||||||
|
risk_flags_json=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
|
||||||
|
archived_nos = {
|
||||||
|
claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "AP-ADMIN-DRAFT" in claim_nos
|
||||||
|
assert "AP-ADMIN-LINKING" in claim_nos
|
||||||
|
assert "EXP-ADMIN-DRAFT" in claim_nos
|
||||||
|
assert "EXP-ADMIN-ARCHIVED" not in claim_nos
|
||||||
|
assert "EXP-ADMIN-ARCHIVED" in archived_nos
|
||||||
|
|
||||||
|
|
||||||
def test_list_claims_limits_executive_to_personal_records() -> None:
|
def test_list_claims_limits_executive_to_personal_records() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="executive@example.com",
|
username="executive@example.com",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import stat
|
import stat
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -88,6 +89,46 @@ print("__OCR_JSON__=" + json.dumps(payload, ensure_ascii=False))
|
|||||||
assert skipped.warnings == ["当前仅支持图片和 PDF 文件进行 OCR。"]
|
assert skipped.warnings == ["当前仅支持图片和 PDF 文件进行 OCR。"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ocr_service_passes_configured_device_to_worker(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
captured_commands: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake_run(
|
||||||
|
command: list[str],
|
||||||
|
*,
|
||||||
|
capture_output: bool,
|
||||||
|
text: bool,
|
||||||
|
timeout: int,
|
||||||
|
check: bool,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
captured_commands.append(command)
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=command,
|
||||||
|
returncode=0,
|
||||||
|
stdout='__OCR_JSON__={"engine":"paddleocr_mobile","model":"PP-OCRv5_mobile","documents":[]}\n',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv("OCR_DEVICE", "gpu:0")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
try:
|
||||||
|
payload = OcrService()._invoke_worker(
|
||||||
|
python_bin="python",
|
||||||
|
worker_path="worker.py",
|
||||||
|
input_paths=[tmp_path / "invoice.png"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
assert payload["engine"] == "paddleocr_mobile"
|
||||||
|
command = captured_commands[0]
|
||||||
|
device_index = command.index("--device")
|
||||||
|
assert command[device_index + 1] == "gpu:0"
|
||||||
|
|
||||||
|
|
||||||
def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -113,6 +113,135 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
|
|||||||
return claim, item
|
return claim, item
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
department = OrganizationUnit(
|
||||||
|
id="dept-org-manager",
|
||||||
|
unit_code="DEPT-ORG-MANAGER",
|
||||||
|
name="交付部",
|
||||||
|
manager_name="王总",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
id="emp-org-manager",
|
||||||
|
employee_no="E30001",
|
||||||
|
name="赵六",
|
||||||
|
email="zhaoliu@example.com",
|
||||||
|
organization_unit=department,
|
||||||
|
position="实施顾问",
|
||||||
|
grade="P5",
|
||||||
|
finance_owner_name="Wang Finance",
|
||||||
|
)
|
||||||
|
duplicated_warning = {
|
||||||
|
"source": "budget_control",
|
||||||
|
"event_type": "budget_warning",
|
||||||
|
"severity": "medium",
|
||||||
|
"label": "预算接近预警线",
|
||||||
|
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%。",
|
||||||
|
"budget_no": "SIM-BUD-2026-R0048",
|
||||||
|
"allocation_id": "allocation-0048",
|
||||||
|
"subject_code": "travel",
|
||||||
|
}
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-org-manager",
|
||||||
|
claim_no="EXP-202606-ORG-MGR",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name=employee.name,
|
||||||
|
department_id=department.id,
|
||||||
|
department_name=department.name,
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel",
|
||||||
|
reason="差旅报销",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("880.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
risk_flags_json=[
|
||||||
|
{**duplicated_warning, "created_at": "2026-06-03T10:00:00+00:00"},
|
||||||
|
{**duplicated_warning, "created_at": "2026-06-03T10:01:00+00:00"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add_all([department, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
headers = {"x-auth-username": "zhaoliu@example.com"}
|
||||||
|
response = client.get("/api/v1/reimbursements/claims/claim-org-manager", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["manager_name"] == "王总"
|
||||||
|
assert payload["finance_owner_name"] == "Wang Finance"
|
||||||
|
budget_warnings = [
|
||||||
|
flag
|
||||||
|
for flag in payload["risk_flags_json"]
|
||||||
|
if flag.get("source") == "budget_control" and flag.get("event_type") == "budget_warning"
|
||||||
|
]
|
||||||
|
assert len(budget_warnings) == 1
|
||||||
|
assert budget_warnings[0]["message"] == duplicated_warning["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
finance_role = Role(
|
||||||
|
id="role-finance-reader",
|
||||||
|
role_code="finance",
|
||||||
|
name="财务",
|
||||||
|
description="可处理财务复核任务",
|
||||||
|
)
|
||||||
|
applicant = Employee(
|
||||||
|
id="emp-finance-stage-applicant",
|
||||||
|
employee_no="E30002",
|
||||||
|
name="钱七",
|
||||||
|
email="qianqi@example.com",
|
||||||
|
position="实施顾问",
|
||||||
|
grade="P5",
|
||||||
|
finance_owner_name="Wang Finance Group",
|
||||||
|
)
|
||||||
|
finance_user = Employee(
|
||||||
|
id="emp-finance-stage-approver",
|
||||||
|
employee_no="F30002",
|
||||||
|
name="Wang Finance",
|
||||||
|
email="wang.finance@example.com",
|
||||||
|
position="财务专员",
|
||||||
|
grade="P6",
|
||||||
|
finance_owner_name="Wang Finance Group",
|
||||||
|
roles=[finance_role],
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-finance-stage-reader",
|
||||||
|
claim_no="EXP-202606-FINANCE-MGR",
|
||||||
|
employee_id=applicant.id,
|
||||||
|
employee_name=applicant.name,
|
||||||
|
department_id=None,
|
||||||
|
department_name="交付部",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel",
|
||||||
|
reason="差旅报销",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("880.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="财务审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add_all([finance_role, applicant, finance_user, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
headers = {"x-auth-username": "qianqi@example.com"}
|
||||||
|
response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["finance_owner_name"] == "Wang Finance Group"
|
||||||
|
assert payload["finance_approver_name"] == "Wang Finance"
|
||||||
|
|
||||||
|
|
||||||
def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None:
|
def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None:
|
||||||
client, session_factory = build_client()
|
client, session_factory = build_client()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -147,3 +147,52 @@ def test_system_dashboard_service_aggregates_real_runtime_metrics() -> None:
|
|||||||
assert dashboard.accuracy_comparison["wrong"][
|
assert dashboard.accuracy_comparison["wrong"][
|
||||||
dashboard.accuracy_comparison["categories"].index("异常诊断")
|
dashboard.accuracy_comparison["categories"].index("异常诊断")
|
||||||
] == 1
|
] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_dashboard_counts_online_users_from_active_sessions_across_window() -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
UserSessionMetric(
|
||||||
|
session_id="session-online-old-001",
|
||||||
|
username="active.user@example.com",
|
||||||
|
display_name="在线用户",
|
||||||
|
email="active.user@example.com",
|
||||||
|
login_at=now - timedelta(days=2),
|
||||||
|
last_activity_at=now - timedelta(days=2),
|
||||||
|
activity_event_count=3,
|
||||||
|
status="active",
|
||||||
|
),
|
||||||
|
UserSessionMetric(
|
||||||
|
session_id="session-online-old-002",
|
||||||
|
username="active.user@example.com",
|
||||||
|
display_name="在线用户",
|
||||||
|
email="active.user@example.com",
|
||||||
|
login_at=now - timedelta(minutes=15),
|
||||||
|
last_activity_at=now - timedelta(minutes=15),
|
||||||
|
activity_event_count=5,
|
||||||
|
status="active",
|
||||||
|
),
|
||||||
|
UserSessionMetric(
|
||||||
|
session_id="session-closed-outside-window",
|
||||||
|
username="offline.user@example.com",
|
||||||
|
display_name="离线用户",
|
||||||
|
email="offline.user@example.com",
|
||||||
|
login_at=now - timedelta(days=3),
|
||||||
|
logout_at=now - timedelta(days=2, hours=23),
|
||||||
|
duration_ms=60 * 60 * 1000,
|
||||||
|
status="closed",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
dashboard = SystemDashboardService(db).build_dashboard(days=1)
|
||||||
|
login_users = dashboard.login_wave["loginUsers"]
|
||||||
|
|
||||||
|
assert dashboard.totals["onlineUsers"] == 1
|
||||||
|
assert max(login_users) == 1
|
||||||
|
assert "00:00" in dashboard.login_wave["labels"]
|
||||||
|
assert "23:00" in dashboard.login_wave["labels"]
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
width var(--sidebar-motion),
|
width var(--sidebar-motion),
|
||||||
flex-basis var(--sidebar-motion),
|
flex-basis var(--sidebar-motion),
|
||||||
box-shadow 160ms var(--ease);
|
box-shadow 160ms var(--ease);
|
||||||
|
animation: loginEntrySidebarIn 520ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.sidebar-collapsed .app-sidebar {
|
.app.sidebar-collapsed .app-sidebar {
|
||||||
@@ -151,7 +152,13 @@
|
|||||||
font-weight: 700;
|
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 {
|
.main.overview-main {
|
||||||
height: var(--desktop-stage-height, 100dvh);
|
height: var(--desktop-stage-height, 100dvh);
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
@@ -201,6 +208,14 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 24px;
|
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 {
|
.workarea.settings-workarea {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
574
web/src/assets/styles/components/personal-workbench-progress.css
Normal file
574
web/src/assets/styles/components/personal-workbench-progress.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,6 +334,19 @@
|
|||||||
justify-items: start;
|
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 {
|
.progress-result {
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
--workbench-chart-amber: var(--chart-amber, #b58b4c);
|
--workbench-chart-amber: var(--chart-amber, #b58b4c);
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 1680px;
|
||||||
|
margin: 0 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -581,6 +583,27 @@
|
|||||||
font-weight: 850;
|
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 {
|
.title-with-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -632,7 +655,65 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
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 {
|
.progress-row:first-child {
|
||||||
@@ -645,15 +726,25 @@
|
|||||||
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
|
animation-delay: calc(300ms + var(--item-index, 0) * 80ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-identity,
|
.progress-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.progress-result {
|
.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-identity strong,
|
||||||
.progress-result strong {
|
.progress-result strong {
|
||||||
margin-bottom: 2px;
|
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--workbench-ink);
|
color: var(--workbench-ink);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -751,45 +842,6 @@
|
|||||||
text-align: left;
|
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 {
|
.progress-time-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -173,8 +173,32 @@
|
|||||||
border-color 180ms var(--ease),
|
border-color 180ms var(--ease),
|
||||||
color 180ms var(--ease);
|
color 180ms var(--ease);
|
||||||
will-change: gap;
|
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 {
|
.nav-btn:hover {
|
||||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
|
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
|
||||||
color: var(--theme-primary-active);
|
color: var(--theme-primary-active);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
:model-value="visible"
|
:model-value="visible"
|
||||||
append-to-body
|
append-to-body
|
||||||
align-center
|
align-center
|
||||||
width="min(1040px, calc(100vw - 48px))"
|
width="min(960px, calc(100vw - 64px))"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
:lock-scroll="true"
|
:lock-scroll="true"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@@ -277,6 +277,8 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.expense-profile-dialog.el-dialog) {
|
:global(.expense-profile-dialog.el-dialog) {
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
max-height: calc(100dvh - 56px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -380,7 +382,8 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-dialog-content {
|
.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;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -469,7 +472,7 @@ watch(
|
|||||||
|
|
||||||
.profile-analysis-grid {
|
.profile-analysis-grid {
|
||||||
display: 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;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,13 +486,13 @@ watch(
|
|||||||
.profile-tags-panel {
|
.profile-tags-panel {
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
min-height: 352px;
|
min-height: 312px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-radar-panel {
|
.profile-radar-panel {
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
min-height: 352px;
|
min-height: 312px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-section-title {
|
.profile-section-title {
|
||||||
@@ -554,11 +557,11 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-tags-panel > .profile-panel-empty {
|
.profile-tags-panel > .profile-panel-empty {
|
||||||
min-height: 284px;
|
min-height: 244px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-radar-empty {
|
.profile-radar-empty {
|
||||||
min-height: 308px;
|
min-height: 268px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-operation-copy strong {
|
.profile-operation-copy strong {
|
||||||
@@ -580,13 +583,13 @@ watch(
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: stretch;
|
justify-items: stretch;
|
||||||
min-height: 360px;
|
min-height: 300px;
|
||||||
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
|
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-radar-chart {
|
.profile-radar-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 360px;
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-behavior-tags {
|
.profile-behavior-tags {
|
||||||
@@ -703,6 +706,97 @@ watch(
|
|||||||
justify-self: end;
|
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 {
|
@keyframes expenseProfileDialogIn {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
:model-value="visible"
|
:model-value="visible"
|
||||||
append-to-body
|
append-to-body
|
||||||
align-center
|
align-center
|
||||||
width="min(980px, calc(100vw - 48px))"
|
width="min(900px, calc(100vw - 64px))"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
:lock-scroll="true"
|
:lock-scroll="true"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@@ -256,6 +256,8 @@ function resolveTagType(tone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.expense-stats-detail-dialog.el-dialog) {
|
:global(.expense-stats-detail-dialog.el-dialog) {
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
max-height: calc(100dvh - 56px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -353,7 +355,9 @@ function resolveTagType(tone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.expense-stats-detail-content {
|
.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;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -372,7 +376,7 @@ function resolveTagType(tone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.expense-stats-analysis-grid {
|
.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,
|
.expense-stats-summary-item,
|
||||||
@@ -432,7 +436,7 @@ function resolveTagType(tone) {
|
|||||||
|
|
||||||
.expense-stats-distribution-panel,
|
.expense-stats-distribution-panel,
|
||||||
.expense-stats-processing-panel {
|
.expense-stats-processing-panel {
|
||||||
min-height: 336px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expense-stats-section-title {
|
.expense-stats-section-title {
|
||||||
@@ -483,17 +487,17 @@ function resolveTagType(tone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.expense-distribution-chart {
|
.expense-distribution-chart {
|
||||||
min-height: 286px;
|
min-height: 250px;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expense-distribution-chart-layout {
|
.expense-distribution-chart-layout {
|
||||||
display: grid;
|
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;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 286px;
|
min-height: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expense-distribution-donut {
|
.expense-distribution-donut {
|
||||||
@@ -501,7 +505,7 @@ function resolveTagType(tone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.expense-distribution-donut :deep(.donut-body) {
|
.expense-distribution-donut :deep(.donut-body) {
|
||||||
height: 220px;
|
height: 192px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,7 +623,7 @@ function resolveTagType(tone) {
|
|||||||
|
|
||||||
.expense-stats-empty {
|
.expense-stats-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 180px;
|
min-height: 150px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -632,6 +636,97 @@ function resolveTagType(tone) {
|
|||||||
text-align: center;
|
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 {
|
@keyframes expenseStatsDialogIn {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -199,65 +199,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workbench-content-grid">
|
<div class="workbench-content-grid">
|
||||||
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
|
<PersonalWorkbenchProgressPanel
|
||||||
<div class="section-head">
|
:progress-items="workbenchSummary.progressItems || []"
|
||||||
<h2>费用进度</h2>
|
@open-target="openWorkbenchTarget"
|
||||||
</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>
|
|
||||||
|
|
||||||
<aside class="side-column">
|
<aside class="side-column">
|
||||||
<article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;">
|
<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 WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||||
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
|
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
|
||||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||||
|
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
|
||||||
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
|
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { useToast } from '../../composables/useToast.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 expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {})
|
||||||
const currentUserProfileKey = computed(() => {
|
const currentUserProfileKey = computed(() => {
|
||||||
const user = currentUser.value || {}
|
const user = currentUser.value || {}
|
||||||
return [
|
return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
|
||||||
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) {
|
function buildSelectedFileKey(file) {
|
||||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||||
}
|
}
|
||||||
@@ -748,7 +610,7 @@ function openWorkbenchTarget(item) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`, SESSION_TYPE_EXPENSE)
|
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的单据进度`, SESSION_TYPE_EXPENSE)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCapabilityAssistant(item) {
|
function openCapabilityAssistant(item) {
|
||||||
|
|||||||
211
web/src/components/business/PersonalWorkbenchProgressPanel.vue
Normal file
211
web/src/components/business/PersonalWorkbenchProgressPanel.vue
Normal 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>
|
||||||
@@ -32,7 +32,7 @@ const chartColors = computed(() => ({
|
|||||||
|
|
||||||
const ariaLabel = computed(() =>
|
const ariaLabel = computed(() =>
|
||||||
props.labels.map((label, index) => (
|
props.labels.map((label, index) => (
|
||||||
`${label}登录${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}次`
|
`${label}在线${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}次`
|
||||||
)).join(';')
|
)).join(';')
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,13 +84,15 @@ const chartOptions = computed(() => ({
|
|||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700
|
fontWeight: 700,
|
||||||
|
interval: props.compact ? 2 : 1,
|
||||||
|
hideOverlap: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: [
|
yAxis: [
|
||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: '登录',
|
name: '在线',
|
||||||
min: 0,
|
min: 0,
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
@@ -115,7 +117,7 @@ const chartOptions = computed(() => ({
|
|||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '登录人数',
|
name: '在线人数',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: 0.42,
|
smooth: 0.42,
|
||||||
symbol: 'circle',
|
symbol: 'circle',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useNavigation, navItems } from './useNavigation.js'
|
|||||||
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
|
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
|
||||||
import { useSystemState } from './useSystemState.js'
|
import { useSystemState } from './useSystemState.js'
|
||||||
import { useToast } from './useToast.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 { fetchOntologyParse } from '../services/ontology.js'
|
||||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||||
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
|
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
|
||||||
@@ -45,6 +45,7 @@ export function useAppShell() {
|
|||||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||||
const selectedRequestSnapshot = ref(null)
|
const selectedRequestSnapshot = ref(null)
|
||||||
const documentCenterRefreshToken = ref(0)
|
const documentCenterRefreshToken = ref(0)
|
||||||
|
const workbenchApprovalRequests = ref([])
|
||||||
|
|
||||||
const { activeView, currentView, setView } = useNavigation()
|
const { activeView, currentView, setView } = useNavigation()
|
||||||
const {
|
const {
|
||||||
@@ -105,6 +106,40 @@ export function useAppShell() {
|
|||||||
return reloadRequests()
|
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) {
|
function isSameRequestIdentity(request, requestId) {
|
||||||
const normalizedId = String(requestId || '').trim()
|
const normalizedId = String(requestId || '').trim()
|
||||||
if (!request || !normalizedId) {
|
if (!request || !normalizedId) {
|
||||||
@@ -185,14 +220,18 @@ export function useAppShell() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (view === 'workbench') {
|
if (view === 'workbench') {
|
||||||
void ensureRequestsLoaded()
|
void reloadWorkbenchRequests()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const workbenchRequests = computed(() =>
|
||||||
|
mergeWorkbenchRequests(requests.value, workbenchApprovalRequests.value)
|
||||||
|
)
|
||||||
|
|
||||||
const workbenchSummary = computed(() =>
|
const workbenchSummary = computed(() =>
|
||||||
buildWorkbenchSummary(requests.value, currentUser.value)
|
buildWorkbenchSummary(workbenchRequests.value, currentUser.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const topBarView = computed(() => {
|
const topBarView = computed(() => {
|
||||||
@@ -451,7 +490,7 @@ export function useAppShell() {
|
|||||||
const status = String(payload.status || payload.claimStatus || '').trim()
|
const status = String(payload.status || payload.claimStatus || '').trim()
|
||||||
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
|
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
|
||||||
const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
|
const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
|
||||||
await reloadRequests()
|
await reloadWorkbenchRequests()
|
||||||
if (status === 'submitted') {
|
if (status === 'submitted') {
|
||||||
if (isApplicationDocument) {
|
if (isApplicationDocument) {
|
||||||
toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`)
|
toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`)
|
||||||
@@ -505,7 +544,7 @@ export function useAppShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRequestUpdated() {
|
async function handleRequestUpdated() {
|
||||||
await reloadRequests()
|
await reloadWorkbenchRequests()
|
||||||
await refreshSelectedRequestDetail(String(route.params.requestId || ''))
|
await refreshSelectedRequestDetail(String(route.params.requestId || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
metricBlueprints,
|
metricBlueprints,
|
||||||
systemMetricBlueprints,
|
systemMetricBlueprints,
|
||||||
systemDashboardTotals as fallbackSystemDashboardTotals,
|
|
||||||
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
|
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
|
||||||
systemLoginWave as fallbackSystemLoginWave,
|
|
||||||
systemTokenDailyWave as fallbackSystemTokenDailyWave,
|
systemTokenDailyWave as fallbackSystemTokenDailyWave,
|
||||||
systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
|
systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
|
||||||
systemUserTokenUsage as fallbackSystemUserTokenUsage,
|
systemUserTokenUsage as fallbackSystemUserTokenUsage,
|
||||||
@@ -78,6 +76,25 @@ const emptyFinanceBudgetMetrics = [
|
|||||||
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
{ 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) {
|
function parseLocalDate(value) {
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -439,13 +456,13 @@ export function useOverviewView(options = {}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const systemDashboardTotals = computed(() => (
|
const systemDashboardTotals = computed(() => (
|
||||||
systemDashboardPayload.value?.totals || fallbackSystemDashboardTotals
|
systemDashboardPayload.value?.totals || emptySystemDashboardTotals
|
||||||
))
|
))
|
||||||
const systemAgentDailyRatio = computed(() => (
|
const systemAgentDailyRatio = computed(() => (
|
||||||
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
|
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
|
||||||
))
|
))
|
||||||
const systemLoginWave = computed(() => (
|
const systemLoginWave = computed(() => (
|
||||||
systemDashboardPayload.value?.loginWave || fallbackSystemLoginWave
|
systemDashboardPayload.value?.loginWave || emptySystemLoginWave
|
||||||
))
|
))
|
||||||
const systemTokenDailyWave = computed(() => (
|
const systemTokenDailyWave = computed(() => (
|
||||||
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave
|
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, reactive, ref } from 'vue'
|
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'
|
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
|
||||||
|
|
||||||
const EXPENSE_TYPE_LABELS = {
|
const EXPENSE_TYPE_LABELS = {
|
||||||
@@ -72,7 +72,6 @@ const APPLICATION_PROGRESS_LABELS = [
|
|||||||
'创建申请',
|
'创建申请',
|
||||||
'直属领导审批',
|
'直属领导审批',
|
||||||
'预算管理者审批',
|
'预算管理者审批',
|
||||||
'审批完成',
|
|
||||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||||
ARCHIVED_STEP_LABEL
|
ARCHIVED_STEP_LABEL
|
||||||
]
|
]
|
||||||
@@ -80,7 +79,6 @@ const APPLICATION_PROGRESS_LABELS = [
|
|||||||
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
|
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
|
||||||
'创建申请',
|
'创建申请',
|
||||||
'直属领导审批',
|
'直属领导审批',
|
||||||
'审批完成',
|
|
||||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||||
ARCHIVED_STEP_LABEL
|
ARCHIVED_STEP_LABEL
|
||||||
]
|
]
|
||||||
@@ -595,17 +593,17 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
|
|||||||
const normalizedNode = String(workflowNode || '').trim()
|
const normalizedNode = String(workflowNode || '').trim()
|
||||||
|
|
||||||
if (approvalMeta.key === 'completed') {
|
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)) {
|
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
|
||||||
return 4
|
return 3
|
||||||
}
|
}
|
||||||
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
|
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
|
||||||
return 3
|
return 2
|
||||||
}
|
}
|
||||||
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
|
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
|
||||||
return 3
|
return 2
|
||||||
}
|
}
|
||||||
if (normalizedNode.includes('预算')) {
|
if (normalizedNode.includes('预算')) {
|
||||||
return 2
|
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) {
|
function resolveApplicationBudgetApproverName(claim) {
|
||||||
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
|
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||||
return resolveDisplayName(
|
return resolveDisplayName(
|
||||||
@@ -708,6 +736,15 @@ function resolveApplicationBudgetApproverName(claim) {
|
|||||||
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
|
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
|
||||||
const normalizedLabel = normalizeText(label)
|
const normalizedLabel = normalizeText(label)
|
||||||
const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
|
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 (
|
if (
|
||||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
&& approvalMeta.key !== 'completed'
|
&& approvalMeta.key !== 'completed'
|
||||||
@@ -796,6 +833,24 @@ function findApprovalEventForStep(claim, label) {
|
|||||||
return getLatestEvent(events)
|
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) {
|
function findLatestReturnEvent(claim) {
|
||||||
return getLatestEvent(
|
return getLatestEvent(
|
||||||
getRiskFlags(claim).filter((flag) => (
|
getRiskFlags(claim).filter((flag) => (
|
||||||
@@ -1066,13 +1121,39 @@ function findMergedApplicationBudgetApprovalEvent(claim) {
|
|||||||
source === 'manual_approval'
|
source === 'manual_approval'
|
||||||
&& eventType === 'expense_application_approval'
|
&& eventType === 'expense_application_approval'
|
||||||
&& previousStage.includes('直属领导')
|
&& previousStage.includes('直属领导')
|
||||||
&& nextStage.includes('审批完成')
|
&& (
|
||||||
|
nextStage.includes('审批完成')
|
||||||
|
|| nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL)
|
||||||
|
|| nextStage.includes('申请完成')
|
||||||
|
)
|
||||||
&& mergedFlag
|
&& 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) {
|
function applicationRequiresBudgetReviewStep(claim, workflowNode) {
|
||||||
const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
|
const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
|
||||||
if (node.includes('预算')) {
|
if (node.includes('预算')) {
|
||||||
@@ -1087,24 +1168,22 @@ function applicationRequiresBudgetReviewStep(claim, workflowNode) {
|
|||||||
const source = normalizeText(flag.source)
|
const source = normalizeText(flag.source)
|
||||||
const eventType = normalizeText(flag.event_type || flag.eventType)
|
const eventType = normalizeText(flag.event_type || flag.eventType)
|
||||||
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
|
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 || {}
|
const routeDecision = flag.route_decision || flag.routeDecision || {}
|
||||||
|
|
||||||
if (source === 'approval_routing' && flag.requires_budget_review === true) {
|
if (source === 'approval_routing' && flag.requires_budget_review === true) {
|
||||||
return true
|
return applicationBudgetRouteMeetsThreshold(flag, flag)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
routeDecision
|
routeDecision
|
||||||
&& typeof routeDecision === 'object'
|
&& typeof routeDecision === 'object'
|
||||||
&& routeDecision.requires_budget_review === true
|
&& routeDecision.requires_budget_review === true
|
||||||
) {
|
) {
|
||||||
return true
|
return applicationBudgetRouteMeetsThreshold(flag, routeDecision)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
source === 'budget_approval'
|
source === 'budget_approval'
|
||||||
|| eventType === 'expense_application_budget_approval'
|
|| eventType === 'expense_application_budget_approval'
|
||||||
|| previousStage.includes('预算')
|
|| previousStage.includes('预算')
|
||||||
|| nextStage.includes('预算')
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1273,7 +1352,7 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
|||||||
? hasApplicationReturnStep
|
? hasApplicationReturnStep
|
||||||
? ['创建申请', '直属领导审批', '退回', '待提交']
|
? ['创建申请', '直属领导审批', '退回', '待提交']
|
||||||
: hasMergedApplicationBudgetApproval
|
: hasMergedApplicationBudgetApproval
|
||||||
? ['创建申请', '直属领导审批', '审批完成', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
|
? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
|
||||||
: shouldShowApplicationBudgetStep
|
: shouldShowApplicationBudgetStep
|
||||||
? APPLICATION_PROGRESS_LABELS
|
? APPLICATION_PROGRESS_LABELS
|
||||||
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
|
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
|
||||||
@@ -1512,6 +1591,8 @@ export function mapExpenseClaimToRequest(claim) {
|
|||||||
employeePosition: String(claim?.employee_position || '').trim(),
|
employeePosition: String(claim?.employee_position || '').trim(),
|
||||||
employeeGrade: String(claim?.employee_grade || '').trim(),
|
employeeGrade: String(claim?.employee_grade || '').trim(),
|
||||||
managerName: resolveDisplayName(claim?.manager_name),
|
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),
|
budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
|
||||||
budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
|
budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
|
||||||
budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
|
budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
|
||||||
@@ -1665,21 +1746,28 @@ export function useRequests() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function reload() {
|
async function reload(options = {}) {
|
||||||
|
const silent = Boolean(options?.silent)
|
||||||
|
if (!silent) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchExpenseClaims()
|
const payload = await fetchAllExpenseClaims()
|
||||||
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
|
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
|
if (!silent) {
|
||||||
requests.value = []
|
requests.value = []
|
||||||
|
}
|
||||||
error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。'
|
error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。'
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function approveRequest(request) {
|
function approveRequest(request) {
|
||||||
return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。`
|
return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。`
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app',
|
path: '/app',
|
||||||
redirect: { name: 'app-overview' }
|
redirect: () => {
|
||||||
|
const { resolveEntryRoute } = useSystemState()
|
||||||
|
return resolveEntryRoute()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/documents',
|
path: '/app/documents',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { apiRequest } from './api.js'
|
import { apiRequest } from './api.js'
|
||||||
|
|
||||||
export const REIMBURSEMENT_LIST_PREVIEW_PARAMS = Object.freeze({ page: 1, pageSize: 100 })
|
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 = {}) {
|
function buildListQuery(params = {}) {
|
||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
@@ -27,18 +29,73 @@ export function extractExpenseClaimItems(payload) {
|
|||||||
return Array.isArray(payload?.items) ? payload.items : []
|
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 = {}) {
|
export function fetchExpenseClaims(params = {}) {
|
||||||
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
|
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAllExpenseClaims(params = {}) {
|
||||||
|
return fetchAllExpenseClaimPages(fetchExpenseClaims, params)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchApprovalExpenseClaims(params = {}) {
|
export function fetchApprovalExpenseClaims(params = {}) {
|
||||||
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`)
|
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAllApprovalExpenseClaims(params = {}) {
|
||||||
|
return fetchAllExpenseClaimPages(fetchApprovalExpenseClaims, params)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchArchivedExpenseClaims(params = {}) {
|
export function fetchArchivedExpenseClaims(params = {}) {
|
||||||
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`)
|
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAllArchivedExpenseClaims(params = {}) {
|
||||||
|
return fetchAllExpenseClaimPages(fetchArchivedExpenseClaims, params)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchExpenseClaimDetail(claimId) {
|
export function fetchExpenseClaimDetail(claimId) {
|
||||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
|
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,8 +273,8 @@ export function filterNavItemsByAccess(navItems, user) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultAuthorizedRoute(user) {
|
export function resolveDefaultAuthorizedRoute(user) {
|
||||||
if (isPlatformAdminUser(user) && canAccessAppView(user, 'overview')) {
|
if (isPlatformAdminUser(user) && canAccessAppView(user, 'documents')) {
|
||||||
return { name: 'app-overview' }
|
return { name: 'app-documents' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstVisibleView = getAccessibleViewIds(user)[0]
|
const firstVisibleView = getAccessibleViewIds(user)[0]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isApplicationRequestLike } from './documentClassification.js'
|
import { isApplicationRequestLike } from './documentClassification.js'
|
||||||
|
import { canProcessApprovalRequest } from './approvalInbox.js'
|
||||||
|
|
||||||
function parseNumber(value) {
|
function parseNumber(value) {
|
||||||
const nextValue = Number(value)
|
const nextValue = Number(value)
|
||||||
@@ -46,6 +47,15 @@ export function belongsToCurrentUser(request, currentUser) {
|
|||||||
return names.some((name) => name === person)
|
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) {
|
export function hasHighRiskFlag(request) {
|
||||||
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
|
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) {
|
function resolveStatusTone(approvalKey) {
|
||||||
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
|
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
|
||||||
if (approvalKey === 'draft') return 'success'
|
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) {
|
function buildProgressItems(ownedRequests) {
|
||||||
return ownedRequests
|
return ownedRequests
|
||||||
.filter((request) => Array.isArray(request?.progressSteps) && request.progressSteps.length)
|
|
||||||
.map((request) => {
|
.map((request) => {
|
||||||
const requestId = resolveRequestIdentity(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 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 status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
|
||||||
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
requestId,
|
requestId,
|
||||||
title,
|
title,
|
||||||
documentTypeLabel,
|
documentTypeLabel,
|
||||||
|
applicantLabel: resolveApplicantLabel(request),
|
||||||
expenseTypeLabel: resolveExpenseCategory(request),
|
expenseTypeLabel: resolveExpenseCategory(request),
|
||||||
amount: formatCurrency(request?.amount),
|
amount: formatCurrency(request?.amount),
|
||||||
status,
|
status,
|
||||||
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), 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,
|
steps,
|
||||||
target: resolveRequestTarget(request),
|
target: resolveRequestTarget(request),
|
||||||
prompt: `查询 ${requestId || title} 的费用进度`
|
prompt: `查询 ${requestId || title} 的单据进度`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
|
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
|
||||||
@@ -486,9 +559,11 @@ function buildExpenseOperationRows(todoItems, notifications, progressItems) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkbenchSummary(requests, currentUser) {
|
export function buildWorkbenchSummary(requests, currentUser) {
|
||||||
const ownedRequests = Array.isArray(requests)
|
const allRequests = Array.isArray(requests)
|
||||||
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
? 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)))
|
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 returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
|
||||||
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
|
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
|
||||||
const todoItems = buildTodoItems(ownedRequests)
|
const todoItems = buildTodoItems(ownedRequests)
|
||||||
const progressItems = buildProgressItems(ownedRequests)
|
const progressItems = buildProgressItems(progressRequests)
|
||||||
const notifications = buildNotifications(todoItems, progressItems)
|
const notifications = buildNotifications(todoItems, progressItems)
|
||||||
const expenseStatsDetail = {
|
const expenseStatsDetail = {
|
||||||
distributionRows: buildExpenseDistributionRows(ownedRequests),
|
distributionRows: buildExpenseDistributionRows(ownedRequests),
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
|
|
||||||
<DocumentsCenterView
|
<DocumentsCenterView
|
||||||
v-else-if="activeView === 'documents'"
|
v-else-if="activeView === 'documents'"
|
||||||
:filtered-requests="filteredRequests"
|
:filtered-requests="requests"
|
||||||
:has-data="requests.length > 0"
|
:has-data="requests.length > 0"
|
||||||
:loading="requestsLoading"
|
:loading="requestsLoading"
|
||||||
:error="requestsError"
|
:error="requestsError"
|
||||||
|
|||||||
@@ -249,10 +249,9 @@ import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
|||||||
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
||||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||||
import {
|
import {
|
||||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
|
||||||
extractExpenseClaimItems,
|
extractExpenseClaimItems,
|
||||||
fetchApprovalExpenseClaims,
|
fetchAllApprovalExpenseClaims,
|
||||||
fetchArchivedExpenseClaims
|
fetchAllArchivedExpenseClaims
|
||||||
} from '../services/reimbursements.js'
|
} from '../services/reimbursements.js'
|
||||||
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
|
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
|
||||||
import {
|
import {
|
||||||
@@ -925,8 +924,8 @@ async function loadSupportingRows() {
|
|||||||
supportingError.value = ''
|
supportingError.value = ''
|
||||||
|
|
||||||
const [approvalResult, archiveResult] = await Promise.allSettled([
|
const [approvalResult, archiveResult] = await Promise.allSettled([
|
||||||
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS),
|
fetchAllApprovalExpenseClaims(),
|
||||||
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
fetchAllArchivedExpenseClaims()
|
||||||
])
|
])
|
||||||
|
|
||||||
if (approvalResult.status === 'fulfilled') {
|
if (approvalResult.status === 'fulfilled') {
|
||||||
|
|||||||
@@ -257,7 +257,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>用户在线波动 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>用户在线波动 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-subtitle">登录人数与互动次数的时段波动。</p>
|
<p class="card-subtitle">在线人数与互动次数的时段波动。</p>
|
||||||
|
|
||||||
<SystemLoginWaveChart
|
<SystemLoginWaveChart
|
||||||
compact
|
compact
|
||||||
|
|||||||
@@ -755,10 +755,7 @@ export default {
|
|||||||
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
||||||
&& !isCurrentApplicant.value
|
&& !isCurrentApplicant.value
|
||||||
))
|
))
|
||||||
const canReturnRequest = computed(() => {
|
const canProcessCurrentApprovalStage = computed(() => {
|
||||||
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (isDirectManagerApprovalStage.value) {
|
if (isDirectManagerApprovalStage.value) {
|
||||||
return isCurrentDirectManagerApprover.value
|
return isCurrentDirectManagerApprover.value
|
||||||
}
|
}
|
||||||
@@ -767,18 +764,16 @@ export default {
|
|||||||
}
|
}
|
||||||
return canProcessFinanceApprovalStage.value
|
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(() =>
|
const canApproveRequest = computed(() =>
|
||||||
(Boolean(props.approvalMode) || isApplicationDocument.value)
|
request.value.approvalKey === 'in_progress'
|
||||||
&& request.value.approvalKey === 'in_progress'
|
|
||||||
&& Boolean(request.value.claimId)
|
&& Boolean(request.value.claimId)
|
||||||
&& (
|
&& canProcessCurrentApprovalStage.value
|
||||||
(
|
|
||||||
isDirectManagerApprovalStage.value
|
|
||||||
&& isCurrentDirectManagerApprover.value
|
|
||||||
)
|
|
||||||
|| canProcessFinanceApprovalStage.value
|
|
||||||
|| canProcessBudgetApprovalStage.value
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
const canViewApprovalRiskAdvice = computed(() => (
|
const canViewApprovalRiskAdvice = computed(() => (
|
||||||
Boolean(request.value.claimId)
|
Boolean(request.value.claimId)
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export function buildFallbackProgressSteps(requestModel = {}) {
|
|||||||
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
|
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
|
||||||
const paid = /已付款/.test(node)
|
const paid = /已付款/.test(node)
|
||||||
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
|
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
|
||||||
|
const archived = /申请归档|已归档/.test(node)
|
||||||
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
|
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
|
||||||
|
|
||||||
if (isApplicationDocumentRequest(requestModel)) {
|
if (isApplicationDocumentRequest(requestModel)) {
|
||||||
@@ -207,11 +208,19 @@ export function buildFallbackProgressSteps(requestModel = {}) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 3,
|
index: 3,
|
||||||
label: '审批完成',
|
label: '关联单据状态',
|
||||||
time: completed ? '已完成' : '待处理',
|
time: hasRelatedApplication ? '已关联' : completed ? '未关联' : '待处理',
|
||||||
done: completed,
|
done: archived,
|
||||||
active: completed,
|
active: completed || archived,
|
||||||
current: false
|
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) {
|
export function buildDraftBlockingIssues(request, expenseItems) {
|
||||||
const issues = []
|
const issues = []
|
||||||
|
const isApplication = isApplicationDocumentRequest(request)
|
||||||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||||
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
|
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
|
||||||
const itemAmountTotal = normalizedItems.reduce((sum, item) => {
|
const itemAmountTotal = normalizedItems.reduce((sum, item) => {
|
||||||
@@ -611,6 +621,25 @@ export function buildDraftBlockingIssues(request, expenseItems) {
|
|||||||
if (isPlaceholderValue(request.profileName)) {
|
if (isPlaceholderValue(request.profileName)) {
|
||||||
issues.push('申请人未完善')
|
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) {
|
if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) {
|
||||||
issues.push('报销类型未完善')
|
issues.push('报销类型未完善')
|
||||||
}
|
}
|
||||||
@@ -657,18 +686,30 @@ export function mapIssueToAdvice(issue) {
|
|||||||
if (text === '报销类型未完善') {
|
if (text === '报销类型未完善') {
|
||||||
return '选择报销类型,明确本次费用归类。'
|
return '选择报销类型,明确本次费用归类。'
|
||||||
}
|
}
|
||||||
|
if (text === '申请类型未完善') {
|
||||||
|
return '补充申请类型,明确本次申请的费用或业务场景。'
|
||||||
|
}
|
||||||
if (text === '报销事由未完善') {
|
if (text === '报销事由未完善') {
|
||||||
return '补充报销事由,说明本次费用用途。'
|
return '补充报销事由,说明本次费用用途。'
|
||||||
}
|
}
|
||||||
|
if (text === '申请事由未完善') {
|
||||||
|
return '补充申请事由,说明本次申请的业务背景。'
|
||||||
|
}
|
||||||
if (text === '业务地点未完善') {
|
if (text === '业务地点未完善') {
|
||||||
return '补充业务地点,方便审核业务发生场景。'
|
return '补充业务地点,方便审核业务发生场景。'
|
||||||
}
|
}
|
||||||
if (text === '发生时间未完善') {
|
if (text === '发生时间未完善') {
|
||||||
return '补充费用发生时间,确保单据时间完整。'
|
return '补充费用发生时间,确保单据时间完整。'
|
||||||
}
|
}
|
||||||
|
if (text === '申请时间未完善') {
|
||||||
|
return '补充申请时间或行程时间,确保申请周期完整。'
|
||||||
|
}
|
||||||
if (text === '报销金额未完善') {
|
if (text === '报销金额未完善') {
|
||||||
return '补充报销金额,并与费用明细金额保持一致。'
|
return '补充报销金额,并与费用明细金额保持一致。'
|
||||||
}
|
}
|
||||||
|
if (text === '预计总费用未完善') {
|
||||||
|
return '补充预计总费用,供审批人判断预算占用和申请额度。'
|
||||||
|
}
|
||||||
|
|
||||||
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
|
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
|
||||||
if (!itemMatch) {
|
if (!itemMatch) {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ test('platform admin users do not enter the personal workbench', () => {
|
|||||||
assert.equal(canAccessAppView(adminUser, 'workbench'), false)
|
assert.equal(canAccessAppView(adminUser, 'workbench'), false)
|
||||||
assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
|
assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
|
||||||
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
|
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
|
||||||
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-overview' })
|
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-documents' })
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),
|
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),
|
||||||
['documents', 'overview', 'settings']
|
['documents', 'overview', 'settings']
|
||||||
@@ -201,6 +201,30 @@ test('direct-manager approval helpers only match claims pushed to the current us
|
|||||||
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false)
|
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('approver executive users can process claims routed to their direct-manager identity', () => {
|
||||||
|
const leaderUser = {
|
||||||
|
roleCodes: ['approver', 'executive'],
|
||||||
|
name: 'Xiang Wanhong',
|
||||||
|
username: 'xiangwanhong@xf.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(canApproveLeaderExpenseClaims(leaderUser), true)
|
||||||
|
assert.equal(
|
||||||
|
isCurrentDirectManagerForRequest(
|
||||||
|
{ person: 'Shen Zhiyuan', managerName: 'Xiang Wanhong' },
|
||||||
|
leaderUser
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
isCurrentDirectManagerForRequest(
|
||||||
|
{ person: 'Xiang Wanhong', managerName: 'Li Wenjing' },
|
||||||
|
leaderUser
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('applicant helper matches generated draft owner by employee identifiers', () => {
|
test('applicant helper matches generated draft owner by employee identifiers', () => {
|
||||||
const currentUser = {
|
const currentUser = {
|
||||||
username: 'caoxiaozhu@xf.com',
|
username: 'caoxiaozhu@xf.com',
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const appShellComposable = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const requestsComposable = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const assistantScript = readFileSync(
|
const assistantScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -70,6 +74,47 @@ test('documents center reloads immediately when entered or clicked again', () =>
|
|||||||
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
|
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('documents center uses the full request list instead of the global date-filtered list', () => {
|
||||||
|
assert.match(
|
||||||
|
appShellRouteView,
|
||||||
|
/<DocumentsCenterView[\s\S]*:filtered-requests="requests"[\s\S]*:has-data="requests\.length > 0"/
|
||||||
|
)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
appShellRouteView,
|
||||||
|
/<DocumentsCenterView[\s\S]*:filtered-requests="filteredRequests"/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench summary merges approval inbox requests without polluting document center rows', () => {
|
||||||
|
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
|
||||||
|
assert.match(appShellComposable, /const workbenchApprovalRequests = ref\(\[\]\)/)
|
||||||
|
assert.match(appShellComposable, /async function reloadWorkbenchApprovalRequests\(\)/)
|
||||||
|
assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\)/)
|
||||||
|
assert.match(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
|
||||||
|
assert.match(appShellComposable, /payload\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
|
||||||
|
assert.match(appShellComposable, /Promise\.all\(\[[\s\S]*reloadRequests\(\{ silent: true \}\),[\s\S]*reloadWorkbenchApprovalRequests\(\)[\s\S]*\]\)/)
|
||||||
|
assert.match(appShellComposable, /if \(view === 'workbench'\) \{[\s\S]*void reloadWorkbenchRequests\(\)/)
|
||||||
|
assert.match(appShellComposable, /const workbenchRequests = computed\(\(\) =>[\s\S]*mergeWorkbenchRequests\(requests\.value, workbenchApprovalRequests\.value\)/)
|
||||||
|
assert.match(appShellComposable, /buildWorkbenchSummary\(workbenchRequests\.value, currentUser\.value\)/)
|
||||||
|
assert.match(appShellRouteView, /<DocumentsCenterView[\s\S]*:filtered-requests="requests"/)
|
||||||
|
assert.doesNotMatch(appShellRouteView, /<DocumentsCenterView[\s\S]*workbenchRequests/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench progress refreshes after homepage create or detail updates', () => {
|
||||||
|
assert.match(appShellComposable, /async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)/)
|
||||||
|
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||||
|
assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench progress refresh is silent to avoid homepage flashing', () => {
|
||||||
|
assert.match(requestsComposable, /async function reload\(options = \{\}\) \{[\s\S]*const silent = Boolean\(options\?\.silent\)/)
|
||||||
|
assert.match(requestsComposable, /if \(!silent\) \{[\s\S]*loading\.value = true[\s\S]*error\.value = ''[\s\S]*\}/)
|
||||||
|
assert.match(requestsComposable, /catch \(nextError\) \{[\s\S]*if \(!silent\) \{[\s\S]*requests\.value = \[\][\s\S]*\}/)
|
||||||
|
assert.match(requestsComposable, /finally \{[\s\S]*if \(!silent\) \{[\s\S]*loading\.value = false[\s\S]*\}/)
|
||||||
|
assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\) \{[\s\S]*reloadRequests\(\{ silent: true \}\)/)
|
||||||
|
assert.match(appShellComposable, /async function reloadDocumentCenterRequests\(\) \{[\s\S]*return reloadRequests\(\)/)
|
||||||
|
})
|
||||||
|
|
||||||
test('document detail navigation preserves document center list query', () => {
|
test('document detail navigation preserves document center list query', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
appShellComposable,
|
appShellComposable,
|
||||||
@@ -86,12 +131,12 @@ test('document detail navigation preserves document center list query', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
|
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
|
||||||
assert.match(appShellComposable, /import \{ fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
|
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
|
||||||
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
|
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
|
||||||
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
||||||
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
||||||
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
||||||
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||||
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ const documentListSharedStyles = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
|
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const reimbursementService = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const requestsComposable = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => {
|
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => {
|
||||||
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
||||||
@@ -134,6 +142,17 @@ test('documents center refresh token reloads supporting approval and archive row
|
|||||||
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
|
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('documents center fetches every paginated claim page for admin-scale lists', () => {
|
||||||
|
assert.match(reimbursementService, /export function fetchAllExpenseClaims/)
|
||||||
|
assert.match(reimbursementService, /async function fetchAllExpenseClaimPages/)
|
||||||
|
assert.match(reimbursementService, /payload\.has_next/)
|
||||||
|
assert.match(requestsComposable, /import \{ fetchAllExpenseClaims \} from '\.\.\/services\/reimbursements\.js'/)
|
||||||
|
assert.match(requestsComposable, /const payload = await fetchAllExpenseClaims\(\)/)
|
||||||
|
assert.match(documentsCenterView, /fetchAllApprovalExpenseClaims/)
|
||||||
|
assert.match(documentsCenterView, /fetchAllArchivedExpenseClaims/)
|
||||||
|
assert.doesNotMatch(documentsCenterView, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
|
||||||
|
})
|
||||||
|
|
||||||
test('documents center list shows created time and conditional stay time columns', () => {
|
test('documents center list shows created time and conditional stay time columns', () => {
|
||||||
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
|
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
|
||||||
assert.match(documentsCenterView, /<col class="col-created">/)
|
assert.match(documentsCenterView, /<col class="col-created">/)
|
||||||
@@ -326,10 +345,9 @@ test('documents center status dropdown uses compact filter styling', () => {
|
|||||||
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
|
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
|
||||||
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
|
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
|
||||||
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)
|
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)
|
||||||
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/)
|
assert.match(documentListSharedStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||||
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/)
|
assert.match(documentListSharedStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/)
|
||||||
assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/)
|
assert.match(documentListSharedStyles, /\.status-dropdown-filter,\s*\.status-filter-trigger,\s*\.status-filter-menu\s*\{[\s\S]*min-width:\s*154px;/)
|
||||||
assert.match(documentsCenterStyles, /\.status-filter-menu\s*\{[\s\S]*min-width:\s*154px;/)
|
|
||||||
assert.doesNotMatch(documentsCenterStyles, /\.document-state-tabs\s*\{/)
|
assert.doesNotMatch(documentsCenterStyles, /\.document-state-tabs\s*\{/)
|
||||||
assert.doesNotMatch(documentsCenterStyles, /\.document-status-filter\s*\{[^}]*margin-top:/)
|
assert.doesNotMatch(documentsCenterStyles, /\.document-status-filter\s*\{[^}]*margin-top:/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ test('expense profile modal remounts the behavior radar when opened', () => {
|
|||||||
assert.match(modal, /scheduleRadarFrame/)
|
assert.match(modal, /scheduleRadarFrame/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('expense profile modal uses compact laptop dialog sizing', () => {
|
||||||
|
assert.match(modal, /width="min\(960px, calc\(100vw - 64px\)\)"/)
|
||||||
|
assert.match(modal, /max-height:\s*min\(580px, calc\(100dvh - 176px\)\)/)
|
||||||
|
assert.match(
|
||||||
|
modal,
|
||||||
|
/@media \(min-width: 861px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 861px\) and \(max-height: 820px\)/
|
||||||
|
)
|
||||||
|
assert.match(modal, /width:\s*min\(900px, calc\(100vw - 96px\)\) !important;/)
|
||||||
|
assert.match(modal, /max-height:\s*min\(520px, calc\(100dvh - 152px\)\)/)
|
||||||
|
assert.match(modal, /\.profile-radar-chart \{[\s\S]*height:\s*248px;/)
|
||||||
|
})
|
||||||
|
|
||||||
test('radar chart uses the shared echarts lifecycle and enables entrance animation', () => {
|
test('radar chart uses the shared echarts lifecycle and enables entrance animation', () => {
|
||||||
assert.match(radarChart, /import \{ useEcharts \} from '\.\.\/\.\.\/composables\/useEcharts\.js'/)
|
assert.match(radarChart, /import \{ useEcharts \} from '\.\.\/\.\.\/composables\/useEcharts\.js'/)
|
||||||
assert.match(radarChart, /useEcharts\(chartElement, chartOptions\)/)
|
assert.match(radarChart, /useEcharts\(chartElement, chartOptions\)/)
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ test('expense stats detail modal exposes distribution, processing time and opera
|
|||||||
assert.match(modal, /resolveDistributionColor/)
|
assert.match(modal, /resolveDistributionColor/)
|
||||||
assert.match(modal, /processingRows/)
|
assert.match(modal, /processingRows/)
|
||||||
assert.match(modal, /operationRows/)
|
assert.match(modal, /operationRows/)
|
||||||
|
assert.match(modal, /width="min\(900px, calc\(100vw - 64px\)\)"/)
|
||||||
|
assert.match(modal, /max-height:\s*min\(580px, calc\(100dvh - 176px\)\)/)
|
||||||
|
assert.match(
|
||||||
|
modal,
|
||||||
|
/@media \(min-width: 861px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 861px\) and \(max-height: 820px\)/
|
||||||
|
)
|
||||||
|
assert.match(modal, /width:\s*min\(860px, calc\(100vw - 96px\)\) !important;/)
|
||||||
|
assert.match(modal, /max-height:\s*min\(520px, calc\(100dvh - 152px\)\)/)
|
||||||
|
assert.match(modal, /\.expense-distribution-donut :deep\(\.donut-body\) \{[\s\S]*height:\s*170px;/)
|
||||||
assert.doesNotMatch(modal, /--expense-detail-percent/)
|
assert.doesNotMatch(modal, /--expense-detail-percent/)
|
||||||
assert.doesNotMatch(modal, /expense-distribution-track/)
|
assert.doesNotMatch(modal, /expense-distribution-track/)
|
||||||
assert.match(modal, /统计口径来自当前工作台已加载的个人单据/)
|
assert.match(modal, /统计口径来自当前工作台已加载的个人单据/)
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ const workbench = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const workbenchProgressPanel = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchProgressPanel.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const workbenchProgressStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-progress.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const workbenchStyles = readFileSync(
|
const workbenchStyles = readFileSync(
|
||||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
|
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -31,7 +39,7 @@ const workbenchInsightStyles = readFileSync(
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
const heroBackgroundAsset = fileURLToPath(
|
const heroBackgroundAsset = fileURLToPath(
|
||||||
new URL('../src/assets/personal-workbench-hero-bg-theme-base.webp', import.meta.url)
|
new URL('../src/assets/images/hero-3d-banner.png', import.meta.url)
|
||||||
)
|
)
|
||||||
const capabilityGlassAsset = fileURLToPath(
|
const capabilityGlassAsset = fileURLToPath(
|
||||||
new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url)
|
new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url)
|
||||||
@@ -43,8 +51,9 @@ const panelGlassAsset = fileURLToPath(
|
|||||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
test('workbench assistant greets the current employee without the old helper tag', () => {
|
||||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||||
assert.doesNotMatch(workbench, /AI 报销助手/)
|
assert.doesNotMatch(workbench, /AI 报销助手/)
|
||||||
assert.match(workbench, /嗨,\{\{ displayUserName \}\},我是您的 <span>AI 费用助手<\/span>/)
|
assert.match(workbench, /\{\{ typedTitlePrefix \}\}<span v-if="titleTypingDone">小财管家<\/span>/)
|
||||||
assert.match(workbench, /placeholder="请输入费用申请、报销问题、预算查询或制度问答\.\.\."/)
|
assert.match(workbench, /const heroTitleText = computed\(\(\) => `嗨,\$\{displayUserName\.value\},我是您的 `\)/)
|
||||||
|
assert.match(workbench, /placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行\.\.\."/)
|
||||||
assert.match(workbench, /const displayUserName = computed/)
|
assert.match(workbench, /const displayUserName = computed/)
|
||||||
assert.match(workbench, /user\.name/)
|
assert.match(workbench, /user\.name/)
|
||||||
})
|
})
|
||||||
@@ -83,16 +92,16 @@ test('workbench capability cards keep user-entered context only', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('workbench hero uses theme-tintable background image', () => {
|
test('workbench hero uses theme-tintable background image', () => {
|
||||||
assert.match(workbench, /personal-workbench-hero-bg-theme-base\.webp/)
|
assert.match(workbench, /hero-3d-banner\.png/)
|
||||||
assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.png/)
|
assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.(webp|png)/)
|
||||||
assert.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/)
|
assert.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/)
|
||||||
assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/)
|
assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/)
|
||||||
assert.match(workbenchStyles, /var\(--assistant-bg-image\) var\(--assistant-bg-position\) \/ var\(--assistant-bg-size\) no-repeat/)
|
assert.match(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\) var\(--assistant-bg-position\) \/ var\(--assistant-decor-width\) auto no-repeat/)
|
||||||
assert.match(workbenchStyles, /background-blend-mode:\s*normal,\s*color,\s*luminosity;/)
|
assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*"";/)
|
||||||
assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*none;/)
|
assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*right center;/)
|
||||||
assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*68% center;/)
|
|
||||||
assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/)
|
assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/)
|
||||||
assert.ok(statSync(heroBackgroundAsset).size < 120 * 1024)
|
assert.ok(statSync(heroBackgroundAsset).size > 1024)
|
||||||
|
assert.ok(statSync(heroBackgroundAsset).size < 600 * 1024)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench cards use layered glass material instead of texture-led cards', () => {
|
test('workbench cards use layered glass material instead of texture-led cards', () => {
|
||||||
@@ -103,13 +112,11 @@ test('workbench cards use layered glass material instead of texture-led cards',
|
|||||||
assert.match(workbenchGlassStyles, /--workbench-glass-highlight:/)
|
assert.match(workbenchGlassStyles, /--workbench-glass-highlight:/)
|
||||||
assert.match(workbenchGlassStyles, /--workbench-glass-noise-opacity:\s*0\.012;/)
|
assert.match(workbenchGlassStyles, /--workbench-glass-noise-opacity:\s*0\.012;/)
|
||||||
assert.match(workbenchGlassStyles, /--workbench-glass-blur:\s*blur\(18px\) saturate\(1\.28\);/)
|
assert.match(workbenchGlassStyles, /--workbench-glass-blur:\s*blur\(18px\) saturate\(1\.28\);/)
|
||||||
assert.match(workbenchGlassStyles, /\.capability-card\s*\{[\s\S]*background-color:\s*rgba\(255,\s*255,\s*255,\s*0\.64\);[\s\S]*backdrop-filter:\s*var\(--workbench-glass-blur\)/)
|
assert.match(workbenchGlassStyles, /\.capability-card\s*\{[\s\S]*background:\s*rgba\(255,\s*255,\s*255,\s*0\.96\);[\s\S]*backdrop-filter:\s*blur\(12px\) saturate\(150%\)/)
|
||||||
assert.match(workbenchGlassStyles, /\.workbench-card\s*\{[\s\S]*background-color:\s*rgba\(255,\s*255,\s*255,\s*0\.66\);[\s\S]*backdrop-filter:\s*var\(--workbench-glass-blur\)/)
|
assert.match(workbenchGlassStyles, /\.workbench-card\s*\{[\s\S]*background:\s*rgba\(255,\s*255,\s*255,\s*0\.96\);[\s\S]*backdrop-filter:\s*blur\(12px\) saturate\(150%\)/)
|
||||||
assert.match(workbenchGlassStyles, /\.capability-card::before,[\s\S]*\.capability-card::after/)
|
assert.match(workbenchGlassStyles, /\.capability-card::before,[\s\S]*\.capability-card::after/)
|
||||||
assert.match(workbenchGlassStyles, /\.capability-card::before\s*\{[\s\S]*var\(--workbench-capability-bg-image\) 0 0 \/ var\(--workbench-capability-tile-size\) repeat;[\s\S]*opacity:\s*var\(--workbench-glass-noise-opacity\);/)
|
|
||||||
assert.match(workbenchGlassStyles, /\.workbench-card::before\s*\{[\s\S]*var\(--workbench-panel-bg-image\) 0 0 \/ var\(--workbench-panel-tile-size\) repeat;[\s\S]*opacity:\s*calc\(var\(--workbench-glass-noise-opacity\) \* 0\.8\);/)
|
|
||||||
assert.match(workbenchGlassStyles, /\.capability-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/)
|
assert.match(workbenchGlassStyles, /\.capability-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/)
|
||||||
assert.match(workbenchGlassStyles, /\.workbench-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/)
|
assert.match(workbenchGlassStyles, /\.workbench-card::before,[\s\S]*\.workbench-card::after\s*\{[\s\S]*display:\s*none !important;/)
|
||||||
assert.doesNotMatch(workbenchGlassStyles, /\.capability-card::after\s*\{[^}]*radial-gradient/)
|
assert.doesNotMatch(workbenchGlassStyles, /\.capability-card::after\s*\{[^}]*radial-gradient/)
|
||||||
assert.doesNotMatch(workbenchGlassStyles, /\.workbench-card::after\s*\{[^}]*radial-gradient/)
|
assert.doesNotMatch(workbenchGlassStyles, /\.workbench-card::after\s*\{[^}]*radial-gradient/)
|
||||||
assert.match(workbenchGlassStyles, /\.workbench-card > \*\s*\{[\s\S]*z-index:\s*1;/)
|
assert.match(workbenchGlassStyles, /\.workbench-card > \*\s*\{[\s\S]*z-index:\s*1;/)
|
||||||
@@ -143,29 +150,67 @@ test('workbench submit shows intent recognition feedback before assistant opens'
|
|||||||
assert.match(workbench, /:readonly="isComposerPending"/)
|
assert.match(workbench, /:readonly="isComposerPending"/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench progress rows show update time first', () => {
|
test('workbench document progress has range filter, document types and empty state', () => {
|
||||||
assert.match(workbench, /class="progress-time"/)
|
assert.match(workbench, /import PersonalWorkbenchProgressPanel from '\.\/PersonalWorkbenchProgressPanel\.vue'/)
|
||||||
assert.match(workbench, /class="progress-type"/)
|
assert.match(workbench, /<PersonalWorkbenchProgressPanel[\s\S]*:progress-items="workbenchSummary\.progressItems \|\| \[\]"[\s\S]*@open-target="openWorkbenchTarget"/)
|
||||||
assert.match(workbench, /费用类型/)
|
assert.match(workbenchProgressPanel, /<h2>单据进度<\/h2>/)
|
||||||
assert.match(workbench, /\{\{ item\.expenseTypeLabel \|\| '其他费用' \}\}/)
|
assert.match(workbenchProgressPanel, /personal-workbench-progress\.css/)
|
||||||
|
assert.match(workbenchProgressPanel, /EnterpriseSelect/)
|
||||||
|
assert.match(workbenchProgressPanel, /近10日/)
|
||||||
|
assert.match(workbenchProgressPanel, /近30日/)
|
||||||
|
assert.match(workbenchProgressPanel, /近3个月/)
|
||||||
|
assert.match(workbenchProgressPanel, /class="progress-range-control"[\s\S]*@click\.stop[\s\S]*@mousedown\.stop[\s\S]*@pointerdown\.stop/)
|
||||||
|
assert.match(workbenchProgressPanel, /:teleported="true"/)
|
||||||
|
assert.match(workbenchProgressPanel, /@click="handleProgressItemClick\(\$event, item\)"/)
|
||||||
|
assert.match(workbenchProgressPanel, /function handleProgressItemClick\(event, item\)/)
|
||||||
|
assert.match(workbenchProgressPanel, /return true/)
|
||||||
|
assert.match(workbenchProgressPanel, /class="progress-time"/)
|
||||||
|
assert.match(workbenchProgressPanel, /class="progress-applicant"/)
|
||||||
|
assert.match(workbenchProgressPanel, /class="progress-table-shell"/)
|
||||||
|
assert.match(workbenchProgressPanel, />\u66f4\u65b0\u65f6\u95f4<\/span>/)
|
||||||
|
assert.doesNotMatch(workbenchProgressPanel, /\u5355\u636e\u52a8\u6001/)
|
||||||
|
assert.doesNotMatch(workbenchProgressPanel, /time-capsule/)
|
||||||
|
assert.doesNotMatch(workbenchProgressPanel, /<small>\u7533\u8bf7\u4eba<\/small>/)
|
||||||
|
assert.match(workbenchProgressPanel, /\{\{ item\.applicantLabel \|\| '待补充' \}\}/)
|
||||||
|
assert.match(workbenchProgressPanel, /class="progress-type"/)
|
||||||
|
assert.match(workbenchProgressPanel, /\{\{ item\.documentTypeLabel \}\} · \{\{ item\.expenseTypeLabel \|\| '其他费用' \}\}/)
|
||||||
|
assert.match(workbenchProgressPanel, /当前范围暂无单据/)
|
||||||
|
assert.match(workbenchProgressPanel, /没有申请单或报销单进度/)
|
||||||
assert.match(workbench, /source:\s*'workbench'/)
|
assert.match(workbench, /source:\s*'workbench'/)
|
||||||
assert.match(workbench, /returnTo:\s*'workbench'/)
|
assert.match(workbench, /returnTo:\s*'workbench'/)
|
||||||
assert.match(workbench, /has-long-duration-divider/)
|
assert.doesNotMatch(workbenchProgressPanel, /has-long-duration-divider/)
|
||||||
assert.match(workbench, /hasLongDurationDivider/)
|
assert.doesNotMatch(workbenchProgressPanel, /LONG_DURATION_DAYS/)
|
||||||
assert.match(workbench, /const LONG_DURATION_DAYS = 10/)
|
assert.doesNotMatch(workbenchProgressPanel, /10日以上/)
|
||||||
assert.match(workbench, /isLongDurationProgress\(item\?\.updatedAt\)/)
|
assert.doesNotMatch(workbenchStyles, /10日以上/)
|
||||||
assert.match(workbench, /<time :datetime="item\.updatedAt \|\| ''">\{\{ item\.displayTime \}\}<\/time>/)
|
assert.doesNotMatch(workbenchStyles, /has-long-duration-divider/)
|
||||||
assert.match(workbench, /displayTime: formatProgressTime\(item\?\.updatedAt\)/)
|
assert.doesNotMatch(workbench, />费用进度</)
|
||||||
assert.match(workbench, /function formatProgressTime\(value\)/)
|
assert.match(workbenchProgressPanel, /<time :datetime="item\.updatedAt \|\| ''">\{\{ item\.displayTime \}\}<\/time>/)
|
||||||
assert.doesNotMatch(workbench, />全部进度/)
|
assert.match(workbenchProgressPanel, /function formatProgressTime\(value\)/)
|
||||||
assert.match(workbenchStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*minmax\(78px,\s*0\.42fr\)[\s\S]*minmax\(84px,\s*0\.42fr\)/)
|
assert.match(workbenchStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*minmax\(118px,\s*0\.58fr\)[\s\S]*minmax\(84px,\s*0\.42fr\)/)
|
||||||
assert.match(workbenchStyles, /\.progress-type\s*\{[\s\S]*justify-self:\s*stretch;[\s\S]*justify-items:\s*center;[\s\S]*text-align:\s*center;/)
|
assert.match(workbenchStyles, /\.progress-type\s*\{[\s\S]*justify-self:\s*stretch;[\s\S]*justify-items:\s*center;[\s\S]*text-align:\s*center;/)
|
||||||
assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*justify-content:\s*center;/)
|
assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*justify-content:\s*center;/)
|
||||||
assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*var\(--workbench-primary-active\)/)
|
assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*var\(--workbench-primary-active\)/)
|
||||||
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*content:\s*"10日以上"/)
|
assert.match(workbenchStyles, /\.progress-list\s*\{[\s\S]*overflow-y:\s*auto;/)
|
||||||
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*left:\s*50%;[\s\S]*transform:\s*translateX\(-50%\);/)
|
assert.match(workbenchStyles, /\.progress-empty-state\s*\{[\s\S]*border:\s*1px dashed/)
|
||||||
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*color:\s*var\(--theme-primary-active\);/)
|
assert.match(workbenchStyles, /\.progress-range-select\s*\{[\s\S]*width:\s*124px;/)
|
||||||
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::after\s*\{[\s\S]*rgba\(var\(--theme-primary-rgb/)
|
assert.match(workbenchProgressStyles, /\.progress-panel\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(workbenchProgressStyles, /--progress-table-columns:[\s\S]*minmax\(96px,\s*0\.44fr\);/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-table-shell\s*\{[\s\S]*grid-template-rows:\s*36px minmax\(0,\s*1fr\);[\s\S]*overflow:\s*hidden;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-table-header\s*\{[\s\S]*grid-template-columns:\s*var\(--progress-table-columns\);/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-table-header\s*\{[\s\S]*height:\s*36px;[\s\S]*max-height:\s*36px;[\s\S]*overflow:\s*hidden;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.header-cell\s*\{[\s\S]*justify-content:\s*center;[\s\S]*white-space:\s*nowrap;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-list\s*\{[\s\S]*grid-auto-rows:\s*minmax\(76px,\s*auto\)/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*var\(--progress-table-columns\);[\s\S]*gap:\s*8px;[\s\S]*min-height:\s*76px;[\s\S]*text-align:\s*center;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-time-wrapper\s*\{[\s\S]*justify-content:\s*center;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-identity\s*\{[\s\S]*align-items:\s*center;[\s\S]*text-align:\s*center;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-applicant\s*\{[\s\S]*justify-items:\s*center;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-result\s*\{[\s\S]*justify-self:\s*stretch;[\s\S]*justify-content:\s*center;[\s\S]*justify-items:\s*center;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-result strong\s*\{[\s\S]*width:\s*100%;[\s\S]*text-align:\s*center;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-steps\s*\{[\s\S]*justify-items:\s*stretch;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-step\s*\{[\s\S]*justify-items:\s*center;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-range-control\s*\{[\s\S]*z-index:\s*6;/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-range-select:deep\(\.el-select__wrapper\)/)
|
||||||
|
assert.match(workbenchProgressStyles, /\.progress-empty-state\s*\{[\s\S]*border:\s*1px dashed/)
|
||||||
assert.match(workbenchStyles, /\.progress-time\s*\{[\s\S]*color:\s*var\(--workbench-muted\);/)
|
assert.match(workbenchStyles, /\.progress-time\s*\{[\s\S]*color:\s*var\(--workbench-muted\);/)
|
||||||
assert.match(workbenchResponsiveStyles, /grid-template-areas:[\s\S]*"time identity result"[\s\S]*"type type type"[\s\S]*"steps steps steps"/)
|
assert.match(workbenchResponsiveStyles, /grid-template-areas:[\s\S]*"time identity result"[\s\S]*"type type type"[\s\S]*"steps steps steps"/)
|
||||||
assert.match(workbenchResponsiveStyles, /\.progress-type\s*\{[\s\S]*grid-area:\s*type;/)
|
assert.match(workbenchResponsiveStyles, /\.progress-type\s*\{[\s\S]*grid-area:\s*type;/)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
||||||
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
||||||
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
|
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
|
||||||
|
const FINANCE_APPROVAL = '\u8d22\u52a1\u5ba1\u6279'
|
||||||
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
|
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
|
||||||
const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001'
|
const APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001'
|
||||||
const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863'
|
const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863'
|
||||||
@@ -21,6 +22,7 @@ const ARCHIVED = '\u5df2\u5f52\u6863'
|
|||||||
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
|
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
|
||||||
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
|
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
|
||||||
const WAIT_BUDGET_P8_EXECUTIVE_APPROVAL = '\u7b49\u5f85 P8 Executive \u6279\u590d'
|
const WAIT_BUDGET_P8_EXECUTIVE_APPROVAL = '\u7b49\u5f85 P8 Executive \u6279\u590d'
|
||||||
|
const WAIT_FINANCE_FIONA_APPROVAL = '\u7b49\u5f85 Fiona Finance \u6279\u590d'
|
||||||
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
|
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
|
||||||
|
|
||||||
test('claim mapper exposes employee identifier for reviewer risk profile lookup', () => {
|
test('claim mapper exposes employee identifier for reviewer risk profile lookup', () => {
|
||||||
@@ -158,7 +160,7 @@ test('application claims are mapped as application documents', () => {
|
|||||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||||
@@ -252,7 +254,7 @@ test('application claims wait for department P8 budget monitor after leader appr
|
|||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
|
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||||
@@ -295,7 +297,7 @@ test('application budget wait label uses claim-level budget approver snapshot',
|
|||||||
assert.equal(request.budgetApproverName, 'P8 Executive')
|
assert.equal(request.budgetApproverName, 'P8 Executive')
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
|
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
|
||||||
})
|
})
|
||||||
@@ -391,7 +393,7 @@ test('approved application claims complete after budget approval', () => {
|
|||||||
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
|
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联')
|
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联')
|
||||||
@@ -417,15 +419,40 @@ test('application claims hide budget step when leader approval also covers budge
|
|||||||
created_at: '2026-05-25T01:30:00.000Z',
|
created_at: '2026-05-25T01:30:00.000Z',
|
||||||
updated_at: '2026-05-25T03:00:00.000Z',
|
updated_at: '2026-05-25T03:00:00.000Z',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
approval_stage: APPROVAL_COMPLETED,
|
approval_stage: APPLICATION_LINK_STATUS,
|
||||||
risk_flags_json: [
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'approval_routing',
|
||||||
|
event_type: 'expense_application_route_decision',
|
||||||
|
requires_budget_review: true,
|
||||||
|
route: 'budget_manager',
|
||||||
|
budget_result: {
|
||||||
|
metrics: {
|
||||||
|
after_usage_rate: '99.27',
|
||||||
|
claim_amount_ratio: '1.27',
|
||||||
|
over_budget_amount: '0.00'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: '2026-05-25T03:00:00.000Z'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: 'manual_approval',
|
source: 'manual_approval',
|
||||||
event_type: 'expense_application_approval',
|
event_type: 'expense_application_approval',
|
||||||
operator: '李预算经理',
|
operator: '李预算经理',
|
||||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||||
next_approval_stage: APPROVAL_COMPLETED,
|
next_approval_stage: APPLICATION_LINK_STATUS,
|
||||||
budget_approval_merged: true,
|
budget_approval_merged: true,
|
||||||
|
route_decision: {
|
||||||
|
requires_budget_review: true,
|
||||||
|
route: 'budget_manager',
|
||||||
|
budget_result: {
|
||||||
|
metrics: {
|
||||||
|
after_usage_rate: '99.27',
|
||||||
|
claim_amount_ratio: '1.27',
|
||||||
|
over_budget_amount: '0.00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
created_at: '2026-05-25T03:00:00.000Z'
|
created_at: '2026-05-25T03:00:00.000Z'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -434,7 +461,7 @@ test('application claims hide budget step when leader approval also covers budge
|
|||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||||
@@ -486,7 +513,7 @@ test('approved application claims hide budget step when dynamic route skipped bu
|
|||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
|
||||||
@@ -494,6 +521,69 @@ test('approved application claims hide budget step when dynamic route skipped bu
|
|||||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('approved application claims hide stale budget route below 90 percent threshold', () => {
|
||||||
|
const request = mapExpenseClaimToRequest({
|
||||||
|
id: 'claim-application-stale-budget-route',
|
||||||
|
claim_no: 'APP-20260525-STALE-BUDGET',
|
||||||
|
employee_name: '张三',
|
||||||
|
department_name: '交付部',
|
||||||
|
manager_name: 'Leader Li',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: 'Project onsite support',
|
||||||
|
location: 'Shanghai',
|
||||||
|
amount: 8500,
|
||||||
|
invoice_count: 0,
|
||||||
|
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||||
|
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||||
|
created_at: '2026-05-25T01:30:00.000Z',
|
||||||
|
updated_at: '2026-05-25T03:00:00.000Z',
|
||||||
|
status: 'approved',
|
||||||
|
approval_stage: APPROVAL_COMPLETED,
|
||||||
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'approval_routing',
|
||||||
|
event_type: 'expense_application_route_decision',
|
||||||
|
requires_budget_review: true,
|
||||||
|
route: 'budget_manager',
|
||||||
|
budget_result: {
|
||||||
|
metrics: {
|
||||||
|
after_usage_rate: '85.00',
|
||||||
|
claim_amount_ratio: '85.00',
|
||||||
|
over_budget_amount: '0.00'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: '2026-05-25T03:00:00.000Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'manual_approval',
|
||||||
|
event_type: 'expense_application_approval',
|
||||||
|
operator: 'Leader Li',
|
||||||
|
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||||
|
next_approval_stage: BUDGET_MANAGER_APPROVAL,
|
||||||
|
route_decision: {
|
||||||
|
requires_budget_review: true,
|
||||||
|
route: 'budget_manager',
|
||||||
|
budget_result: {
|
||||||
|
metrics: {
|
||||||
|
after_usage_rate: '85.00',
|
||||||
|
claim_amount_ratio: '85.00',
|
||||||
|
over_budget_amount: '0.00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: '2026-05-25T03:00:00.000Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
request.progressSteps.map((step) => step.label),
|
||||||
|
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
|
)
|
||||||
|
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||||
|
})
|
||||||
|
|
||||||
test('approved application claims show linked reimbursement status before archive', () => {
|
test('approved application claims show linked reimbursement status before archive', () => {
|
||||||
const request = mapExpenseClaimToRequest({
|
const request = mapExpenseClaimToRequest({
|
||||||
id: 'claim-application-linked-draft',
|
id: 'claim-application-linked-draft',
|
||||||
@@ -566,7 +656,7 @@ test('application claims are archived only after linked reimbursement is paid',
|
|||||||
assert.equal(request.workflowNode, APPLICATION_ARCHIVE)
|
assert.equal(request.workflowNode, APPLICATION_ARCHIVE)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
|
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||||
assert.equal(request.secondaryStatusValue, '已归档')
|
assert.equal(request.secondaryStatusValue, '已归档')
|
||||||
@@ -582,6 +672,8 @@ test('progress steps show approval operator time and current stay duration', ()
|
|||||||
claim_no: 'EXP-202605-001',
|
claim_no: 'EXP-202605-001',
|
||||||
employee_name: '张三',
|
employee_name: '张三',
|
||||||
department_name: '市场部',
|
department_name: '市场部',
|
||||||
|
finance_owner_name: 'Wang Finance Group',
|
||||||
|
finance_approver_name: 'Fiona Finance',
|
||||||
expense_type: 'transport',
|
expense_type: 'transport',
|
||||||
reason: '交通报销',
|
reason: '交通报销',
|
||||||
location: '上海',
|
location: '上海',
|
||||||
@@ -592,21 +684,21 @@ test('progress steps show approval operator time and current stay duration', ()
|
|||||||
created_at: '2026-05-20T01:30:00.000Z',
|
created_at: '2026-05-20T01:30:00.000Z',
|
||||||
updated_at: '2026-05-20T03:30:00.000Z',
|
updated_at: '2026-05-20T03:30:00.000Z',
|
||||||
status: 'submitted',
|
status: 'submitted',
|
||||||
approval_stage: '财务审批',
|
approval_stage: FINANCE_APPROVAL,
|
||||||
risk_flags_json: [
|
risk_flags_json: [
|
||||||
{
|
{
|
||||||
source: 'manual_approval',
|
source: 'manual_approval',
|
||||||
operator: '李经理',
|
operator: '李经理',
|
||||||
previous_approval_stage: '直属领导审批',
|
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||||
next_approval_stage: '财务审批',
|
next_approval_stage: FINANCE_APPROVAL,
|
||||||
created_at: '2026-05-20T03:30:00.000Z'
|
created_at: '2026-05-20T03:30:00.000Z'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
items: []
|
items: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
const leaderStep = request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)
|
||||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
const financeStep = request.progressSteps.find((step) => step.rawLabel === FINANCE_APPROVAL)
|
||||||
const firstStep = request.progressSteps[0]
|
const firstStep = request.progressSteps[0]
|
||||||
|
|
||||||
assert.equal(request.riskSummary, '无')
|
assert.equal(request.riskSummary, '无')
|
||||||
@@ -615,6 +707,10 @@ test('progress steps show approval operator time and current stay duration', ()
|
|||||||
assert.match(leaderStep.detail, /2026-05-20/)
|
assert.match(leaderStep.detail, /2026-05-20/)
|
||||||
assert.match(leaderStep.title, /李经理审批通过/)
|
assert.match(leaderStep.title, /李经理审批通过/)
|
||||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||||
|
assert.equal(request.financeOwnerName, 'Wang Finance Group')
|
||||||
|
assert.equal(request.financeApproverName, 'Fiona Finance')
|
||||||
|
assert.equal(financeStep.label, WAIT_FINANCE_FIONA_APPROVAL)
|
||||||
|
assert.equal(financeStep.rawLabel, FINANCE_APPROVAL)
|
||||||
assert.equal(financeStep.current, true)
|
assert.equal(financeStep.current, true)
|
||||||
assert.equal(financeStep.time, '停留 1小时30分钟')
|
assert.equal(financeStep.time, '停留 1小时30分钟')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1124,6 +1220,7 @@ test('current direct manager step shows how long the claim has stayed there', ()
|
|||||||
claim_no: 'EXP-202605-002',
|
claim_no: 'EXP-202605-002',
|
||||||
employee_name: '王五',
|
employee_name: '王五',
|
||||||
department_name: '市场部',
|
department_name: '市场部',
|
||||||
|
manager_name: '李经理',
|
||||||
expense_type: 'office',
|
expense_type: 'office',
|
||||||
reason: '办公用品',
|
reason: '办公用品',
|
||||||
location: '上海',
|
location: '上海',
|
||||||
@@ -1139,11 +1236,13 @@ test('current direct manager step shows how long the claim has stayed there', ()
|
|||||||
items: []
|
items: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
const leaderStep = request.progressSteps.find((step) => step.rawLabel === '直属领导审批')
|
||||||
const submitStep = request.progressSteps.find((step) => step.label === '待提交')
|
const submitStep = request.progressSteps.find((step) => step.label === '待提交')
|
||||||
|
|
||||||
assert.equal(submitStep.time, '王五提交')
|
assert.equal(submitStep.time, '王五提交')
|
||||||
assert.match(submitStep.detail, /2026-05-20/)
|
assert.match(submitStep.detail, /2026-05-20/)
|
||||||
|
assert.equal(leaderStep.label, '等待 李经理 批复')
|
||||||
|
assert.equal(leaderStep.rawLabel, '直属领导审批')
|
||||||
assert.equal(leaderStep.current, true)
|
assert.equal(leaderStep.current, true)
|
||||||
assert.equal(leaderStep.time, '停留 3小时15分钟')
|
assert.equal(leaderStep.time, '停留 3小时15分钟')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
|||||||
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
|
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
|
||||||
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
|
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
|
||||||
assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/)
|
assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/)
|
||||||
|
assert.match(detailScript, /const canProcessCurrentApprovalStage = computed/)
|
||||||
assert.match(detailScript, /approvalOpinionTitle/)
|
assert.match(detailScript, /approvalOpinionTitle/)
|
||||||
assert.match(detailScript, /approvalConfirmDescription/)
|
assert.match(detailScript, /approvalConfirmDescription/)
|
||||||
assert.doesNotMatch(detailScript, /approvalNextStage/)
|
assert.doesNotMatch(detailScript, /approvalNextStage/)
|
||||||
@@ -84,9 +85,14 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
|||||||
/const showApplicationLeaderOpinion = computed\(\(\) => \(\s*isApplicationDocument\.value\s*&& hasLeaderApprovalEvents\.value\s*\)\)/
|
/const showApplicationLeaderOpinion = computed\(\(\) => \(\s*isApplicationDocument\.value\s*&& hasLeaderApprovalEvents\.value\s*\)\)/
|
||||||
)
|
)
|
||||||
assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/)
|
assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/)
|
||||||
assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/)
|
assert.match(detailScript, /if \(isDirectManagerApprovalStage\.value\) \{[\s\S]*return isCurrentDirectManagerApprover\.value/)
|
||||||
assert.match(detailScript, /canProcessFinanceApprovalStage\.value/)
|
assert.match(detailScript, /canProcessFinanceApprovalStage\.value/)
|
||||||
assert.match(detailScript, /canProcessBudgetApprovalStage\.value/)
|
assert.match(detailScript, /canProcessBudgetApprovalStage\.value/)
|
||||||
|
assert.match(detailScript, /const canApproveRequest = computed\(\(\) =>\s*request\.value\.approvalKey === 'in_progress'[\s\S]*&& canProcessCurrentApprovalStage\.value\s*\)/)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
detailScript,
|
||||||
|
/const canApproveRequest = computed\(\(\) =>\s*\(Boolean\(props\.approvalMode\) \|\| isApplicationDocument\.value\)/
|
||||||
|
)
|
||||||
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
|
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
|
||||||
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
|
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
|
||||||
assert.match(detailScript, /resolveApproveErrorMessage/)
|
assert.match(detailScript, /resolveApproveErrorMessage/)
|
||||||
|
|||||||
@@ -1169,6 +1169,28 @@ test('draft submit validation uses expense detail date and amount when claim sum
|
|||||||
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
|
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('returned application submit validation does not require expense detail rows', () => {
|
||||||
|
const issues = buildDraftBlockingIssues(
|
||||||
|
{
|
||||||
|
id: 'AP-202606060001-RETURNED',
|
||||||
|
claimNo: 'AP-202606060001-RETURNED',
|
||||||
|
documentTypeCode: 'application',
|
||||||
|
approvalKey: 'rejected',
|
||||||
|
profileName: '曹笑竹',
|
||||||
|
typeLabel: '差旅费用申请',
|
||||||
|
typeCode: 'travel_application',
|
||||||
|
reason: '支撑国网仿生产环境部署',
|
||||||
|
location: '上海',
|
||||||
|
occurredDisplay: '2026-02-20 至 2026-02-23',
|
||||||
|
amountValue: 1880
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(issues, [])
|
||||||
|
assert.equal(issues.includes('费用明细不能为空'), false)
|
||||||
|
})
|
||||||
|
|
||||||
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
|
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
|
||||||
const routeItem = buildExpenseItemViewModel(
|
const routeItem = buildExpenseItemViewModel(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ test('workbench summary builds real user notifications and progress from request
|
|||||||
assert.equal(summary.todoItems.length, 1)
|
assert.equal(summary.todoItems.length, 1)
|
||||||
assert.equal(summary.todoItems[0].target.id, 'claim-1')
|
assert.equal(summary.todoItems[0].target.id, 'claim-1')
|
||||||
assert.equal(summary.progressItems.length, 1)
|
assert.equal(summary.progressItems.length, 1)
|
||||||
|
assert.match(summary.progressItems[0].prompt, /单据进度/)
|
||||||
|
assert.doesNotMatch(summary.progressItems[0].prompt, /费用进度/)
|
||||||
|
assert.equal(summary.progressItems[0].applicantLabel, '张三')
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
summary.progressItems[0].steps.map((step) => step.label),
|
summary.progressItems[0].steps.map((step) => step.label),
|
||||||
['创建单据', '待提交', '直属领导审批', '财务审批']
|
['创建单据', '待提交', '直属领导审批', '财务审批']
|
||||||
@@ -136,3 +139,65 @@ test('workbench progress keeps application document type for AP claims', () => {
|
|||||||
['申请单', '申请单']
|
['申请单', '申请单']
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('workbench progress includes application rows without backend progress steps', () => {
|
||||||
|
const summary = buildWorkbenchSummary(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'AP-202606060001-NOSTEPS',
|
||||||
|
claimId: 'application-without-steps',
|
||||||
|
claimNo: 'AP-202606060001-NOSTEPS',
|
||||||
|
documentTypeCode: 'application',
|
||||||
|
person: currentUser.name,
|
||||||
|
title: '上海出差申请',
|
||||||
|
approvalKey: 'in_progress',
|
||||||
|
approvalStatus: '直属领导审批',
|
||||||
|
amount: 1880,
|
||||||
|
updatedAt: '2026-06-06T09:00:00+08:00'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
currentUser
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(summary.progressItems.length, 1)
|
||||||
|
assert.equal(summary.progressItems[0].documentTypeLabel, '申请单')
|
||||||
|
assert.equal(summary.progressItems[0].applicantLabel, currentUser.name)
|
||||||
|
assert.deepEqual(
|
||||||
|
summary.progressItems[0].steps.map((step) => step.label),
|
||||||
|
['创建申请', '直属领导审批', '关联单据状态', '已归档']
|
||||||
|
)
|
||||||
|
assert.equal(summary.progressItems[0].steps[1].current, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench progress includes application documents assigned to current approver', () => {
|
||||||
|
const approverUser = {
|
||||||
|
name: '李经理',
|
||||||
|
username: 'manager@example.com',
|
||||||
|
roleCodes: ['approver']
|
||||||
|
}
|
||||||
|
const summary = buildWorkbenchSummary(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'AP-202606060002-APPROVAL',
|
||||||
|
claimId: 'application-for-approver',
|
||||||
|
claimNo: 'AP-202606060002-APPROVAL',
|
||||||
|
documentTypeCode: 'application',
|
||||||
|
person: '张三',
|
||||||
|
managerName: '李经理',
|
||||||
|
title: '北京出差申请',
|
||||||
|
approvalKey: 'in_progress',
|
||||||
|
approvalStatus: '直属领导审批',
|
||||||
|
workflowNode: '直属领导审批',
|
||||||
|
amount: 2600,
|
||||||
|
updatedAt: '2026-06-06T11:00:00+08:00'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
approverUser
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(summary.totalCount, 0)
|
||||||
|
assert.equal(summary.progressItems.length, 1)
|
||||||
|
assert.equal(summary.progressItems[0].documentTypeLabel, '申请单')
|
||||||
|
assert.equal(summary.progressItems[0].applicantLabel, '张三')
|
||||||
|
assert.equal(summary.progressItems[0].requestId, 'AP-202606060002-APPROVAL')
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user