From 74cb192e4c5cf6a710c383f7df065a05cef7fe27 Mon Sep 17 00:00:00 2001 From: yelan Date: Wed, 30 Sep 2026 17:39:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20=E9=A6=96=E9=A1=B5=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=8E=9F=E5=9E=8B=E5=B9=B6=E9=87=8D=E6=9E=84=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF=E4=B8=8E=E5=BE=85=E5=8A=9E=E6=95=B0=E6=8D=AE=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/docs/workbench-home-api.md | 20 ++- clinical-web/src/api/workbench/home.ts | 104 +++++++---- .../home/components/DocumentRankingPanel.vue | 69 +++++-- .../home/components/HomeMetricCard.vue | 5 + .../home/components/SigningTrendPanel.vue | 169 +++++++++++------- .../workbench/home/components/TodoPanel.vue | 23 ++- .../src/views/workbench/home/index.vue | 144 ++++++++++++++- .../src/views/workbench/home/types.ts | 2 +- 8 files changed, 402 insertions(+), 134 deletions(-) diff --git a/clinical-web/docs/workbench-home-api.md b/clinical-web/docs/workbench-home-api.md index a15b382..7724fba 100644 --- a/clinical-web/docs/workbench-home-api.md +++ b/clinical-web/docs/workbench-home-api.md @@ -13,16 +13,16 @@ ## 2. 已接入的请求 ```http -GET /api/v1/workbench/overview?campusId={UUID}&period={7|14|30}&todoLimit=8&rankingLimit=10 +GET /api/v1/workbench/overview?campusId={UUID}&period={7|14|30}&todoLimit=5&rankingLimit=10 X-Token: {登录令牌} ``` -| 参数 | 必填 | 前端传值 | 契约说明 | -| ------------ | ---- | ------------------------- | ------------------------------ | -| campusId | 是 | 当前工作台院区对应的 UUID | 必须在当前用户数据范围内 | -| period | 是 | 7、14 或 30 | 同时决定趋势和文档排名统计周期 | -| todoLimit | 否 | 8 | 待办返回数量,范围 1–20 | -| rankingLimit | 否 | 10 | 排名返回数量,范围 1–20 | +| 参数 | 必填 | 前端传值 | 契约说明 | +| ------------ | ---- | ------------------------- | ------------------------------------------------------------------- | +| campusId | 是 | 当前工作台院区对应的 UUID | 必须在当前用户数据范围内 | +| period | 是 | 7、14 或 30 | 同时决定趋势和文档排名统计周期 | +| todoLimit | 否 | 5 | 待办返回数量,范围 1–20;与原型首页展示上限一致(`.slice(0, 5)`) | +| rankingLimit | 否 | 10 | 排名返回数量,范围 1–20 | 请求通过 utils/request.ts 的单例发送,实际浏览器路径为 /api/v1/workbench/overview,由 Vite 开发代理转发到后端。 @@ -31,12 +31,14 @@ X-Token: {登录令牌} | 页面区域 | 后端字段 | 前端展示 | | ---------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | 四张指标卡 | summary.todaySigned、todaySignedChange、pendingPatientSigning、todayOverdue、availableTemplates、coveredDepartments | 今日签署、较昨日变化、待签署、今日超时、可用模板及覆盖科室 | -| 签署趋势 | trend[].label、trend[].signedCount | 按后端业务时区生成的近 7、14 或 30 天趋势 | +| 签署趋势 | trend[].label、trend[].signedCount | 取返回窗口**末 7 天**,固定标题「近一周签署客户趋势」 | | 我的待办 | todos[].taskId、patientId、patientName、documentName、departmentName、status、signMethod、updatedAt | 待办列表;点击 taskId 跳转签署工作台 | | 文档排名 | documentRanking[].templateId、documentName、departmentName、signedCount | Top10 文档签署排名 | 趋势和排名都直接使用后端按 signedAt、医院业务时区聚合的结果;前端不再用 createdAt 或浏览器时区重新计算。 +**趋势窗口固定近一周**(原型 V10.2 首页只有这一档,7/14/30 开关挂在 Top10 排名卡上):请求里的 `period` 同时决定趋势与排名窗口,所以前端取 `trend` 的**最后 7 天**展示,标题固定「近一周签署客户趋势」—— 选「近 30 天」时排名窗口变、趋势图仍是近一周,与原型一致。若后端后续把两个周期拆成两个参数,这里可以去掉截断。 + ## 4. 待办状态展示 后端 TodoItem.status 使用签署任务状态枚举。前端按下列方式展示,避免把生成中、失败或已作废任务误标为待签署: @@ -63,7 +65,7 @@ pendingPatientSigning 的后端口径只统计 WAITING_SIGN;CREATED 和 GENERA ## 6. 联调验收标准 - 切换院区后,请求携带新 campusId,四张指标卡、趋势、待办和排名同步更新。 -- 切换 7、14、30 天后,趋势标题、趋势数据和文档排名同步变化。 +- 切换 7、14、30 天后,**文档排名**的统计窗口与数值同步变化;趋势图保持「近一周」(末 7 天)不变,与原型一致。 - 返回空数组时,趋势、待办和排名显示各自空状态;指标卡显示 0。 - 服务端返回 GENERATING、FAILED、VOIDED 等状态时,页面标签与状态含义一致。 - Network 中不再出现首页为统计目的而循环拉取全部签署任务或模板的请求。 diff --git a/clinical-web/src/api/workbench/home.ts b/clinical-web/src/api/workbench/home.ts index d3a711c..c3b6c37 100644 --- a/clinical-web/src/api/workbench/home.ts +++ b/clinical-web/src/api/workbench/home.ts @@ -24,7 +24,8 @@ const useMockData = mockFlags.workbenchHome interface MockCampusOverview { summary: WorkbenchOverviewResponse['summary'] - trend: WorkbenchTrendPoint[] + /** 趋势的日均人次基数(按「院区 + 日期」播种出每天的具体值,见 createTrend) */ + trendBase: number todos: WorkbenchTodoTask[] ranking: Array & { baseCount: number }> } @@ -39,15 +40,7 @@ const mockCampusOverviews: Record = { availableTemplates: 128, coveredDepartments: 9, }, - trend: [ - { label: '8/21', value: 24 }, - { label: '8/22', value: 31 }, - { label: '8/23', value: 28 }, - { label: '8/24', value: 35 }, - { label: '8/25', value: 32 }, - { label: '8/26', value: 39 }, - { label: '8/27', value: 36 }, - ], + trendBase: 32, todos: [ { id: 'T0818003', @@ -128,15 +121,7 @@ const mockCampusOverviews: Record = { availableTemplates: 76, coveredDepartments: 6, }, - trend: [ - { label: '8/21', value: 15 }, - { label: '8/22', value: 18 }, - { label: '8/23', value: 17 }, - { label: '8/24', value: 23 }, - { label: '8/25', value: 20 }, - { label: '8/26', value: 25 }, - { label: '8/27', value: 22 }, - ], + trendBase: 20, todos: [ { id: 'T0818001', @@ -216,15 +201,7 @@ const mockCampusOverviews: Record = { availableTemplates: 54, coveredDepartments: 5, }, - trend: [ - { label: '8/21', value: 11 }, - { label: '8/22', value: 14 }, - { label: '8/23', value: 13 }, - { label: '8/24', value: 16 }, - { label: '8/25', value: 15 }, - { label: '8/26', value: 19 }, - { label: '8/27', value: 17 }, - ], + trendBase: 15, todos: [ { id: 'T0818021', @@ -301,6 +278,62 @@ function getPeriodFactor(period: HomeRankingPeriod) { return period === 7 ? 1 : period === 14 ? 2 : 3.5 } +/** 「院区 + 日期」→ 固定种子:同一天同一院区的数值不随刷新、切换时段或翻页而变 */ +function hashSeed(text: string) { + let hash = 2166136261 + + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + + return hash >>> 0 +} + +/** mulberry32:小、快、同种子可复现(与 api/workbench/report-analytics.ts 同一套做法) */ +function createRandom(seed: number) { + let state = seed >>> 0 + + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * 演示趋势:近 period 天(含今天)的已签署人次。 + * + * 为什么不用写死的日期数组:写死 8/21–8/27 之后,演示日一过「近 7 天」窗口就全是过期日期 + * (仓库的演示数据铁律:**日期相对** + **确定性**,不要 Math.random)。 + * 末点取今日已签署,与指标卡同源 —— 原型里趋势最后一点就是今天已签署的任务数。 + */ +function createTrend( + campus: WorkbenchCampus, + summary: WorkbenchOverviewResponse['summary'], + period: HomeRankingPeriod, +): WorkbenchTrendPoint[] { + const points: WorkbenchTrendPoint[] = [] + const base = (mockCampusOverviews[campus] ?? mockCampusOverviews['本部院区']).trendBase + + for (let offset = period - 1; offset >= 0; offset -= 1) { + const day = new Date() + day.setDate(day.getDate() - offset) + const label = `${day.getMonth() + 1}/${day.getDate()}` + const value = + offset === 0 + ? summary.todaySigned + : Math.max(base + Math.round(createRandom(hashSeed(`${campus}-${label}`))() * 12 - 5), 0) + + points.push({ label, value }) + } + + return points +} + function createRanking( rows: MockCampusOverview['ranking'], period: HomeRankingPeriod, @@ -317,7 +350,13 @@ function createRanking( .sort((left, right) => right.signedCount - left.signedCount) } -const TODO_LIMIT = 8 +/** + * 首页「我的待办」最多几条。 + * + * 照抄原型 `renderDash()` 的 `tasks.slice(0, 5)`(最多 5 条)。 + * 仓库多一步:把这个上限同时作为 `todoLimit` 传给后端,避免取回一批再丢掉。 + */ +const TODO_LIMIT = 5 const RANKING_LIMIT = 10 let campusIds: Map | null = null let campusIdsRequest: Promise> | null = null @@ -423,11 +462,12 @@ export async function getWorkbenchOverview( ): Promise { if (useMockData) { const overview = mockCampusOverviews[query.campus] ?? mockCampusOverviews['本部院区'] + const summary = { ...overview.summary } return { - summary: { ...overview.summary }, - trend: overview.trend.map((point) => ({ ...point })), - todos: overview.todos.map((task) => ({ ...task })), + summary, + trend: createTrend(query.campus, summary, query.rankingPeriod), + todos: overview.todos.slice(0, TODO_LIMIT).map((task) => ({ ...task })), documentRanking: createRanking(overview.ranking, query.rankingPeriod), } } diff --git a/clinical-web/src/views/workbench/home/components/DocumentRankingPanel.vue b/clinical-web/src/views/workbench/home/components/DocumentRankingPanel.vue index 75e19fa..5705866 100644 --- a/clinical-web/src/views/workbench/home/components/DocumentRankingPanel.vue +++ b/clinical-web/src/views/workbench/home/components/DocumentRankingPanel.vue @@ -21,15 +21,30 @@ const periodOptions: Array<{ label: string; value: HomeRankingPeriod }> = [ const rankColors = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f'] -const maxCount = computed(() => Math.max(...props.rows.map((row) => row.signedCount), 1)) +/** 原型 svgHBar:mx = ceil(maxV * 1.15) —— 最长的一条也不顶满刻度 */ +const scaleMax = computed(() => + Math.ceil(Math.max(...props.rows.map((row) => row.signedCount), 1) * 1.15), +) + +/** X 轴刻度 0 / ¼ / ½ / ¾ / mx(原型按 mx 四等分取整) */ +const axisTicks = computed(() => + [0, 1, 2, 3, 4].map((index) => Math.round((scaleMax.value * index) / 4)), +) function getBarWidth(row: HomeDocumentRanking) { - return `${Math.max((row.signedCount / maxCount.value) * 100, 1.5)}%` + return `${Math.max((row.signedCount / scaleMax.value) * 100, 1.5)}%` } function getRankColor(index: number) { return rankColors[index % rankColors.length] } + +/** 原型柱体是「本色 → 本色 55% 透明」的横向渐变(stop-opacity 1 → .55) */ +function getBarBackground(index: number) { + const color = getRankColor(index) + + return `linear-gradient(90deg, ${color}, ${color}8c)` +} @@ -200,7 +216,6 @@ function getRankColor(index: number) { height: 100%; min-width: 2px; border-radius: 4px; - opacity: 0.82; } .ranking-count { @@ -219,11 +234,41 @@ function getRankColor(index: number) { } .ranking-empty { - display: grid; + display: flex; min-height: 210px; + flex-direction: column; + align-items: center; + justify-content: center; color: var(--mut); font-size: 13px; - place-items: center; + text-align: center; +} + +.empty-icon { + margin-bottom: 8px; + font-size: 34px; + opacity: 0.5; +} + +.empty-desc { + margin-top: 2px; + font-size: 12px; +} + +/* 原型空态按钮是 ghost 款(btn gh sm),与趋势图空态的主色按钮不同 */ +.empty-action { + margin-top: 12px; + padding: 7px 16px; + color: var(--brand); + font-size: 12px; + font-weight: 700; + background: #fff; + border: 1px solid var(--brand); + border-radius: 7px; +} + +.empty-action:hover { + background: var(--brand-l); } @media (max-width: 900px) { diff --git a/clinical-web/src/views/workbench/home/components/HomeMetricCard.vue b/clinical-web/src/views/workbench/home/components/HomeMetricCard.vue index bf99e6e..b280c12 100644 --- a/clinical-web/src/views/workbench/home/components/HomeMetricCard.vue +++ b/clinical-web/src/views/workbench/home/components/HomeMetricCard.vue @@ -132,6 +132,11 @@ const emit = defineEmits<{ background: var(--warn-l); } +.metric-icon--info { + color: var(--info); + background: var(--info-l); +} + .metric-icon--danger { color: var(--err); background: var(--err-l); diff --git a/clinical-web/src/views/workbench/home/components/SigningTrendPanel.vue b/clinical-web/src/views/workbench/home/components/SigningTrendPanel.vue index 276b91c..3566c45 100644 --- a/clinical-web/src/views/workbench/home/components/SigningTrendPanel.vue +++ b/clinical-web/src/views/workbench/home/components/SigningTrendPanel.vue @@ -1,37 +1,38 @@ @@ -159,16 +165,10 @@ const gridLines = computed(() => } .trend-grid line { - stroke: #eef4f6; + stroke: #e6eef2; stroke-dasharray: 3 4; } -.trend-grid text, -.trend-point text { - fill: var(--mut); - font-size: 10px; -} - .trend-area { fill: url('#home-trend-area'); } @@ -178,24 +178,61 @@ const gridLines = computed(() => stroke: var(--brand); stroke-linecap: round; stroke-linejoin: round; - stroke-width: 2.5; + stroke-width: 2.6; +} + +/* 数值直接标在点上(原型 svgLine 的 ,加粗深色),所以不需要 Y 轴刻度 */ +.trend-point__value { + fill: var(--brand-d); + font-size: 11px; + font-weight: 700; +} + +.trend-point__label { + fill: var(--mut); + font-size: 11px; } .trend-point circle { fill: #fff; stroke: var(--brand); - stroke-width: 2; -} - -.trend-point text { - dominant-baseline: auto; + stroke-width: 2.2; } .trend-empty { - display: grid; - min-height: 210px; + display: flex; + min-height: 208px; + flex-direction: column; + align-items: center; + justify-content: center; color: var(--mut); font-size: 13px; - place-items: center; + text-align: center; +} + +.empty-icon { + margin-bottom: 8px; + font-size: 34px; + opacity: 0.5; +} + +.empty-desc { + margin-top: 2px; + color: var(--mut); + font-size: 12px; +} + +.empty-action { + margin-top: 12px; + padding: 7px 16px; + color: #fff; + font-size: 12px; + font-weight: 700; + background: var(--brand); + border-radius: 7px; +} + +.empty-action:hover { + background: var(--brand-d); } diff --git a/clinical-web/src/views/workbench/home/components/TodoPanel.vue b/clinical-web/src/views/workbench/home/components/TodoPanel.vue index cce2d20..54d8965 100644 --- a/clinical-web/src/views/workbench/home/components/TodoPanel.vue +++ b/clinical-web/src/views/workbench/home/components/TodoPanel.vue @@ -147,18 +147,20 @@ function getHint(item: HomeTodoItem) { .todo-list { display: flex; flex-direction: column; - gap: 8px; + /* 原型 .titem 是 margin-bottom:10px */ + gap: 10px; } .todo-item { display: block; width: 100%; - padding: 9px 11px; + /* 原型 .titem:padding 11px 13px + 圆角 10px */ + padding: 11px 13px; color: var(--ink); text-align: left; background: #fff; border: 1px solid var(--line); - border-radius: 9px; + border-radius: 10px; transition: border-color 0.15s ease, background 0.15s ease; @@ -195,8 +197,9 @@ function getHint(item: HomeTodoItem) { .todo-status { flex-shrink: 0; - padding: 2px 8px; - font-size: 11px; + /* 原型 .tag:padding 2px 10px / 11.5px / 圆角 20px */ + padding: 2px 10px; + font-size: 11.5px; font-weight: 700; border-radius: 20px; } @@ -227,15 +230,18 @@ function getHint(item: HomeTodoItem) { background: var(--ok-l); } +/* 原型 .tg-void */ .todo-status--muted { - color: var(--mut); - background: #eef3f5; + color: #8a9aa3; + background: #eceef0; } .todo-row--detail { margin-top: 5px; + gap: 12px; + flex-wrap: wrap; color: var(--mut); - font-size: 12px; + font-size: 12.5px; } .todo-document { @@ -247,7 +253,6 @@ function getHint(item: HomeTodoItem) { .todo-hint { flex-shrink: 0; - margin-left: auto; white-space: nowrap; } diff --git a/clinical-web/src/views/workbench/home/index.vue b/clinical-web/src/views/workbench/home/index.vue index bc2a42e..8f4ee15 100644 --- a/clinical-web/src/views/workbench/home/index.vue +++ b/clinical-web/src/views/workbench/home/index.vue @@ -6,6 +6,7 @@ import { useRouter } from 'vue-router' import { getWorkbenchOverview } from '@/api/workbench/home' import type { WorkbenchCampus, WorkbenchOverviewResponse } from '@/api/workbench/types' import { useAppStore } from '@/stores/app' +import { useLoginStore } from '@/stores/login' import DocumentRankingPanel from './components/DocumentRankingPanel.vue' import HomeMetricCard from './components/HomeMetricCard.vue' @@ -22,11 +23,14 @@ import type { const router = useRouter() const appStore = useAppStore() +const loginStore = useLoginStore() const loading = ref(true) const loadFailed = ref(false) const overview = ref(null) const rankingPeriod = ref(14) +/** 数据快照时间:原型在渲染时把当前时刻写进欢迎条,取到数据那一刻即为快照时刻 */ +const snapshotAt = ref(null) let overviewRequestId = 0 const selectedCampus = computed(() => appStore.selectedCampus) @@ -56,8 +60,9 @@ const metrics = computed(() => { key: 'pending', label: '待患者签署', value: summary?.pendingPatientSigning ?? 0, - tone: 'brand', - detail: '含手写板 / 短信 · 点击查看', + // 原型这张卡的图标是 --info-l 底 + #3a7bd5 笔画,不是品牌色 + tone: 'info', + detail: '含手写板 / 短信链接 / Pad · 点击查看', detailTone: 'muted', }, { @@ -73,13 +78,73 @@ const metrics = computed(() => { label: '可用文档模板(份)', value: summary?.availableTemplates ?? 0, tone: 'brand', - detail: `覆盖 ${summary?.coveredDepartments ?? 0} 个科室 · 点击查看`, + // 原型:没有可用模板时副文案换成引导去文档库,拼「覆盖 0 个科室」会读不通 + detail: summary?.availableTemplates + ? `覆盖 ${summary?.coveredDepartments ?? 0} 个科室 · 点击查看` + : '暂无可用模板 · 去文档库发布', detailTone: 'muted', }, ] }) -const trendPoints = computed(() => overview.value?.trend ?? []) +const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'] +/** 按时段取问候语(原型 renderDash()):到点为止的最后一档命中 */ +const HOUR_GREETINGS: Array<[number, string]> = [ + [6, '凌晨好'], + [9, '早上好'], + [12, '上午好'], + [14, '中午好'], + [18, '下午好'], +] + +function pad2(value: number) { + return String(value).padStart(2, '0') +} + +const greeting = computed(() => { + const hour = new Date().getHours() + const prefix = HOUR_GREETINGS.find(([limit]) => hour < limit)?.[1] ?? '晚上好' + + return `${prefix},${loginStore.userName}` +}) + +/** 「2026-09-30 星期三 · 本部院区」——日期与当前院区一行 */ +const greetingDate = computed(() => { + const now = new Date() + const date = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}` + const campus = selectedCampus.value ? ` · ${selectedCampus.value}` : '' + + return `${date} 星期${WEEKDAY_LABELS[now.getDay()]}${campus}` +}) + +/** 待办数 = 待患者签署 + 今日已超时(原型 wlTodoN) */ +const pendingTodoCount = computed( + () => + (overview.value?.summary.pendingPatientSigning ?? 0) + + (overview.value?.summary.todayOverdue ?? 0), +) + +/** 数据快照口径 MM-DD HH:MM(原型 fmtT()),未取到数据前保持原型占位 --:-- */ +const snapshotTime = computed(() => { + const at = snapshotAt.value + + if (!at) { + return '--:--' + } + + return `${pad2(at.getMonth() + 1)}-${pad2(at.getDate())} ${pad2(at.getHours())}:${pad2(at.getMinutes())}` +}) + +/** + * 原型趋势图固定「近一周」,与排名卡的 7/14/30 开关无关 + * (原型里趋势的标题、x 轴都是近 7 日,只有 Top10 排名跟着时段按钮变)。 + * 但后端 `period` 一个参数同时决定趋势与排名窗口(见 docs/workbench-home-api.md), + * 所以这里取返回窗口的**最后 7 天**:period=7 时就是全量,period=30 时仍是近一周。 + */ +const HOME_TREND_DAYS = 7 +const trendPoints = computed(() => + (overview.value?.trend ?? []).slice(-HOME_TREND_DAYS), +) const todoItems = computed(() => overview.value?.todos ?? []) const rankingRows = computed(() => overview.value?.documentRanking ?? []) @@ -106,6 +171,7 @@ async function loadOverview() { } overview.value = nextOverview + snapshotAt.value = new Date() } catch { if (requestId !== overviewRequestId) { return @@ -154,6 +220,11 @@ function handlePeriodChange(period: HomeRankingPeriod) { rankingPeriod.value = period } +/** 趋势图空态的原型入口是「+ 发起签署任务」(工作台 + 打开新增弹窗),仓库里只跳工作台 */ +function handleCreateTask() { + goToSigning() +} + function retryLoad() { void loadOverview() } @@ -169,6 +240,20 @@ onMounted(() => {