merge: 合并远程更新并保留签名与报表接入

This commit is contained in:
xy
2026-09-30 18:31:36 +08:00
14 changed files with 577 additions and 512 deletions
+11 -9
View File
@@ -13,16 +13,16 @@
## 2. 已接入的请求 ## 2. 已接入的请求
```http ```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=3&rankingLimit=10
X-Token: {登录令牌} X-Token: {登录令牌}
``` ```
| 参数 | 必填 | 前端传值 | 契约说明 | | 参数 | 必填 | 前端传值 | 契约说明 |
| ------------ | ---- | ------------------------- | ------------------------------ | | ------------ | ---- | ------------------------- | ------------------------------------------------------------------------------------ |
| campusId | 是 | 当前工作台院区对应的 UUID | 必须在当前用户数据范围内 | | campusId | 是 | 当前工作台院区对应的 UUID | 必须在当前用户数据范围内 |
| period | 是 | 7、14 或 30 | 同时决定趋势和文档排名统计周期 | | period | 是 | 7、14 或 30 | 同时决定趋势和文档排名统计周期 |
| todoLimit | 否 | 8 | 待办返回数量,范围 1–20 | | todoLimit | 否 | 3 | 待办返回数量,范围 1–20;原型首页展示上限是 5(`.slice(0, 5)`),仓库按布局收成 3 条 |
| rankingLimit | 否 | 10 | 排名返回数量,范围 1–20 | | rankingLimit | 否 | 10 | 排名返回数量,范围 1–20 |
请求通过 utils/request.ts 的单例发送,实际浏览器路径为 /api/v1/workbench/overview,由 Vite 开发代理转发到后端。 请求通过 utils/request.ts 的单例发送,实际浏览器路径为 /api/v1/workbench/overview,由 Vite 开发代理转发到后端。
@@ -31,12 +31,14 @@ X-Token: {登录令牌}
| 页面区域 | 后端字段 | 前端展示 | | 页面区域 | 后端字段 | 前端展示 |
| ---------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | ---------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| 四张指标卡 | summary.todaySigned、todaySignedChange、pendingPatientSigning、todayOverdue、availableTemplates、coveredDepartments | 今日签署、较昨日变化、待签署、今日超时、可用模板及覆盖科室 | | 四张指标卡 | 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 跳转签署工作台 | | 我的待办 | todos[].taskId、patientId、patientName、documentName、departmentName、status、signMethod、updatedAt | 待办列表;点击 taskId 跳转签署工作台 |
| 文档排名 | documentRanking[].templateId、documentName、departmentName、signedCount | Top10 文档签署排名 | | 文档排名 | documentRanking[].templateId、documentName、departmentName、signedCount | Top10 文档签署排名 |
趋势和排名都直接使用后端按 signedAt、医院业务时区聚合的结果;前端不再用 createdAt 或浏览器时区重新计算。 趋势和排名都直接使用后端按 signedAt、医院业务时区聚合的结果;前端不再用 createdAt 或浏览器时区重新计算。
**趋势窗口固定近一周**(原型 V10.2 首页只有这一档,7/14/30 开关挂在 Top10 排名卡上):请求里的 `period` 同时决定趋势与排名窗口,所以前端取 `trend` 的**最后 7 天**展示,标题固定「近一周签署客户趋势」—— 选「近 30 天」时排名窗口变、趋势图仍是近一周,与原型一致。若后端后续把两个周期拆成两个参数,这里可以去掉截断。
## 4. 待办状态展示 ## 4. 待办状态展示
后端 TodoItem.status 使用签署任务状态枚举。前端按下列方式展示,避免把生成中、失败或已作废任务误标为待签署: 后端 TodoItem.status 使用签署任务状态枚举。前端按下列方式展示,避免把生成中、失败或已作废任务误标为待签署:
@@ -63,7 +65,7 @@ pendingPatientSigning 的后端口径只统计 WAITING_SIGN;CREATED 和 GENERA
## 6. 联调验收标准 ## 6. 联调验收标准
- 切换院区后,请求携带新 campusId,四张指标卡、趋势、待办和排名同步更新。 - 切换院区后,请求携带新 campusId,四张指标卡、趋势、待办和排名同步更新。
- 切换 7、14、30 天后,趋势标题、趋势数据和文档排名同步变化。 - 切换 7、14、30 天后,**文档排名**的统计窗口与数值同步变化;趋势图保持「近一周」(末 7 天)不变,与原型一致。
- 返回空数组时,趋势、待办和排名显示各自空状态;指标卡显示 0。 - 返回空数组时,趋势、待办和排名显示各自空状态;指标卡显示 0。
- 服务端返回 GENERATING、FAILED、VOIDED 等状态时,页面标签与状态含义一致。 - 服务端返回 GENERATING、FAILED、VOIDED 等状态时,页面标签与状态含义一致。
- Network 中不再出现首页为统计目的而循环拉取全部签署任务或模板的请求。 - Network 中不再出现首页为统计目的而循环拉取全部签署任务或模板的请求。
+74 -32
View File
@@ -24,7 +24,8 @@ const useMockData = mockFlags.workbenchHome
interface MockCampusOverview { interface MockCampusOverview {
summary: WorkbenchOverviewResponse['summary'] summary: WorkbenchOverviewResponse['summary']
trend: WorkbenchTrendPoint[] /** 趋势的日均人次基数(按「院区 + 日期」播种出每天的具体值,见 createTrend) */
trendBase: number
todos: WorkbenchTodoTask[] todos: WorkbenchTodoTask[]
ranking: Array<Omit<WorkbenchDocumentRanking, 'signedCount'> & { baseCount: number }> ranking: Array<Omit<WorkbenchDocumentRanking, 'signedCount'> & { baseCount: number }>
} }
@@ -39,15 +40,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
availableTemplates: 128, availableTemplates: 128,
coveredDepartments: 9, coveredDepartments: 9,
}, },
trend: [ trendBase: 32,
{ 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 },
],
todos: [ todos: [
{ {
id: 'T0818003', id: 'T0818003',
@@ -128,15 +121,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
availableTemplates: 76, availableTemplates: 76,
coveredDepartments: 6, coveredDepartments: 6,
}, },
trend: [ trendBase: 20,
{ 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 },
],
todos: [ todos: [
{ {
id: 'T0818001', id: 'T0818001',
@@ -216,15 +201,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
availableTemplates: 54, availableTemplates: 54,
coveredDepartments: 5, coveredDepartments: 5,
}, },
trend: [ trendBase: 15,
{ 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 },
],
todos: [ todos: [
{ {
id: 'T0818021', id: 'T0818021',
@@ -301,6 +278,62 @@ function getPeriodFactor(period: HomeRankingPeriod) {
return period === 7 ? 1 : period === 14 ? 2 : 3.5 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( function createRanking(
rows: MockCampusOverview['ranking'], rows: MockCampusOverview['ranking'],
period: HomeRankingPeriod, period: HomeRankingPeriod,
@@ -317,7 +350,15 @@ function createRanking(
.sort((left, right) => right.signedCount - left.signedCount) .sort((left, right) => right.signedCount - left.signedCount)
} }
const TODO_LIMIT = 8 /**
* 首页「我的待办」最多几条。
*
* 原型 `renderDash()` 是 `tasks.slice(0, 5)`(最多 5 条)。
* 仓库收成 **3 条**(2026-09-30 定):趋势卡与待办卡同行,待办越多行高越高,
* 图是按宽度定高的、贴底后会在标题与图之间留大片空白 —— 3 条时两张卡高度基本齐平。
* 该上限同时作为 `todoLimit` 传给后端,避免取回一批再丢掉。
*/
const TODO_LIMIT = 3
const RANKING_LIMIT = 10 const RANKING_LIMIT = 10
let campusIds: Map<string, string> | null = null let campusIds: Map<string, string> | null = null
let campusIdsRequest: Promise<Map<string, string>> | null = null let campusIdsRequest: Promise<Map<string, string>> | null = null
@@ -423,11 +464,12 @@ export async function getWorkbenchOverview(
): Promise<WorkbenchOverviewResponse> { ): Promise<WorkbenchOverviewResponse> {
if (useMockData) { if (useMockData) {
const overview = mockCampusOverviews[query.campus] ?? mockCampusOverviews['本部院区'] const overview = mockCampusOverviews[query.campus] ?? mockCampusOverviews['本部院区']
const summary = { ...overview.summary }
return { return {
summary: { ...overview.summary }, summary,
trend: overview.trend.map((point) => ({ ...point })), trend: createTrend(query.campus, summary, query.rankingPeriod),
todos: overview.todos.map((task) => ({ ...task })), todos: overview.todos.slice(0, TODO_LIMIT).map((task) => ({ ...task })),
documentRanking: createRanking(overview.ranking, query.rankingPeriod), documentRanking: createRanking(overview.ranking, query.rankingPeriod),
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@@ -49,18 +49,10 @@ const {
selectMethod, selectMethod,
selectTemplate, selectTemplate,
selectVisit, selectVisit,
selectedTemplate,
selectedTemplateId, selectedTemplateId,
selectedVisit, selectedVisit,
selectedVisitId, selectedVisitId,
smsDestination, smsDestination,
staffAnswers,
manualFormFields,
signerType,
signerName,
signerRelation,
staffAnswerHasOption,
toggleStaffAnswerOption,
submit: submitForm, submit: submitForm,
submitting, submitting,
suggestionOpen, suggestionOpen,
@@ -478,79 +470,6 @@ watch(
</div> </div>
</section> </section>
</div> </div>
<section v-if="selectedTemplate" class="form-section signing-context-section">
<header class="section-header">
<span class="section-index">E</span>
<strong>签署人和文书填写项</strong>
<span class="section-hint">按已选模板采集,提交后固化到签署任务</span>
</header>
<div class="section-body signing-context-grid">
<fieldset class="signer-choice">
<legend>实际签署人</legend>
<label>
<input v-model="signerType" type="radio" value="SELF" />
患者本人
</label>
<label>
<input v-model="signerType" type="radio" value="FAMILY" />
家属代签
</label>
<template v-if="signerType === 'FAMILY'">
<input v-model="signerName" maxlength="255" placeholder="代签人姓名" />
<input v-model="signerRelation" maxlength="64" placeholder="与患者关系" />
</template>
<p v-if="errors.signer" class="field-error">{{ errors.signer }}</p>
</fieldset>
<div v-if="manualFormFields.length" class="staff-answer-fields">
<label v-for="field in manualFormFields" :key="field.id" class="staff-answer-field">
<span>{{ field.label }}<b v-if="field.required"> *</b></span>
<select
v-if="field.kind === 'choice'"
v-model="staffAnswers[field.id]"
:required="field.required"
>
<option value="">请选择</option>
<option v-for="option in field.options ?? []" :key="option" :value="option">
{{ option }}
</option>
</select>
<span
v-else-if="field.kind === 'checkbox' && field.options?.length"
class="staff-answer-options"
>
<label v-for="option in field.options" :key="option">
<input
type="checkbox"
:checked="staffAnswerHasOption(field.id, option)"
@change="
toggleStaffAnswerOption(
field.id,
option,
($event.target as HTMLInputElement).checked,
)
"
/>
{{ option }}
</label>
</span>
<input
v-else
v-model="staffAnswers[field.id]"
:type="field.kind === 'date' ? 'date' : 'text'"
:required="field.required"
maxlength="4000"
:placeholder="`填写${field.label}`"
/>
</label>
<p v-if="errors.staffAnswers" class="field-error">{{ errors.staffAnswers }}</p>
</div>
<p v-else class="section-empty">
当前模板没有需要医护端填写的正文项;患者签名和签署日期将在签署环节生成。
</p>
</div>
</section>
</div> </div>
<footer class="dialog-footer"> <footer class="dialog-footer">
@@ -985,80 +904,6 @@ watch(
font-size: 12px; font-size: 12px;
} }
.section-hint {
margin-left: auto;
color: var(--mut);
font-size: 11px;
}
.signing-context-grid {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.3fr);
gap: 14px;
}
.signer-choice {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-content: start;
padding: 10px;
border: 1px solid var(--line);
border-radius: 7px;
}
.signer-choice legend,
.staff-answer-field > span:first-child {
color: var(--mut);
font-size: 12px;
}
.signer-choice label,
.staff-answer-options label {
display: inline-flex;
gap: 5px;
align-items: center;
color: var(--ink);
font-size: 12px;
}
.signer-choice > input,
.staff-answer-field > input,
.staff-answer-field > select {
min-width: 0;
padding: 7px 9px;
border: 1px solid var(--line);
border-radius: 6px;
}
.staff-answer-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.staff-answer-field {
display: grid;
gap: 5px;
min-width: 0;
font-size: 12px;
}
.staff-answer-field b {
color: var(--err);
}
.staff-answer-options {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.signing-context-grid > .section-empty,
.staff-answer-fields > .field-error {
grid-column: 1 / -1;
}
/* C + D 并排 */ /* C + D 并排 */
.document-method-grid { .document-method-grid {
@@ -1469,10 +1314,6 @@ watch(
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.signing-context-grid,
.staff-answer-fields {
grid-template-columns: 1fr;
}
.document-method-grid { .document-method-grid {
flex-direction: column; flex-direction: column;
} }
@@ -1,4 +1,4 @@
import { computed, reactive, ref, toValue, watch, type MaybeRefOrGetter } from 'vue' import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { sendSigningSms } from '@/api/workbench/deliveries' import { sendSigningSms } from '@/api/workbench/deliveries'
@@ -15,7 +15,6 @@ import type {
PatientProfile, PatientProfile,
PatientVisit, PatientVisit,
SigningMethod, SigningMethod,
SigningFormField,
SigningTaskRecord, SigningTaskRecord,
SigningTemplate, SigningTemplate,
WorkbenchCampus, WorkbenchCampus,
@@ -70,10 +69,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
const selectedVisitId = ref('') const selectedVisitId = ref('')
const method = ref<SigningMethod>('pad') const method = ref<SigningMethod>('pad')
const smsDestination = ref('') const smsDestination = ref('')
const staffAnswers = reactive<Record<string, string>>({})
const signerType = ref<'SELF' | 'FAMILY'>('SELF')
const signerName = ref('')
const signerRelation = ref('')
const loadingTemplates = ref(false) const loadingTemplates = ref(false)
const locating = ref(false) const locating = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -93,8 +88,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
visit: '', visit: '',
document: '', document: '',
sms: '', sms: '',
staffAnswers: '',
signer: '',
}) })
/** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */ /** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */
@@ -108,30 +101,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null, () => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
) )
const manualFormFields = computed(() => {
const systemFields = new Set([
'patient',
'sex',
'age',
'visit',
'phone',
'doctor',
'signedDate',
])
return (selectedTemplate.value?.formFields ?? []).filter(
(field) =>
field.kind !== 'signature' &&
field.kind !== 'checkbox' &&
field.kind !== 'choice' &&
!systemFields.has(field.id),
)
})
watch(selectedTemplateId, () => {
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
errors.staffAnswers = ''
})
/** 关联处方:随选中就诊带出(仅演示数据) */ /** 关联处方:随选中就诊带出(仅演示数据) */
const prescription = computed<LinkedPrescription | null>(() => const prescription = computed<LinkedPrescription | null>(() =>
isSigningMockEnabled ? buildLinkedPrescription(selectedVisit.value) : null, isSigningMockEnabled ? buildLinkedPrescription(selectedVisit.value) : null,
@@ -341,8 +310,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
errors.visit = '' errors.visit = ''
errors.document = '' errors.document = ''
errors.sms = '' errors.sms = ''
errors.staffAnswers = ''
errors.signer = ''
} }
function resetForm() { function resetForm() {
@@ -356,10 +323,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedVisitId.value = '' selectedVisitId.value = ''
method.value = initialTemplate?.supportedMethods[0] ?? 'pad' method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
smsDestination.value = '' smsDestination.value = ''
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
signerType.value = 'SELF'
signerName.value = ''
signerRelation.value = ''
phonePlain.value = false phonePlain.value = false
suggestionOpen.value = false suggestionOpen.value = false
selectedDepartment.value = '' selectedDepartment.value = ''
@@ -377,10 +340,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedTemplateId.value = initialTemplate?.id ?? '' selectedTemplateId.value = initialTemplate?.id ?? ''
method.value = initialTemplate?.supportedMethods[0] ?? method.value method.value = initialTemplate?.supportedMethods[0] ?? method.value
smsDestination.value = '' smsDestination.value = ''
Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key])
signerType.value = 'SELF'
signerName.value = ''
signerRelation.value = ''
errors.document = '' errors.document = ''
errors.sms = '' errors.sms = ''
createTaskIdempotencyKey = newIdempotencyKey() createTaskIdempotencyKey = newIdempotencyKey()
@@ -583,18 +542,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
return `${value.slice(0, 3)}****${value.slice(-4)}` return `${value.slice(0, 3)}****${value.slice(-4)}`
} }
function toggleStaffAnswerOption(fieldId: string, option: string, checked: boolean) {
const selected = (staffAnswers[fieldId] ?? '').split('、').filter(Boolean)
const next = checked
? [...new Set([...selected, option])]
: selected.filter((item) => item !== option)
staffAnswers[fieldId] = next.join('、')
}
function staffAnswerHasOption(fieldId: string, option: string) {
return (staffAnswers[fieldId] ?? '').split('、').includes(option)
}
function validate() { function validate() {
clearErrors() clearErrors()
let valid = true let valid = true
@@ -626,22 +573,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
} }
} }
const missingField = manualFormFields.value.find(
(field: SigningFormField) => field.required && !staffAnswers[field.id]?.trim(),
)
if (missingField) {
errors.staffAnswers = `请填写必填项:${missingField.label}`
valid = false
}
if (
signerType.value === 'FAMILY' &&
(!signerName.value.trim() || !signerRelation.value.trim())
) {
errors.signer = '家属代签必须填写签署人姓名和与患者关系'
valid = false
}
return valid return valid
} }
@@ -689,10 +620,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
documentContentHtml: selectedTemplate.value.contentHtml, documentContentHtml: selectedTemplate.value.contentHtml,
// 需科室确认由文书模板标记带出(原型 V6.9 起弹窗里不再让人选) // 需科室确认由文书模板标记带出(原型 V6.9 起弹窗里不再让人选)
needConfirm: selectedTemplate.value.needConfirm ?? false, needConfirm: selectedTemplate.value.needConfirm ?? false,
staffAnswers: { ...staffAnswers }, // 签署人固定为患者本人:发起弹窗不再采集签署人 / 代签信息,
signerType: signerType.value, // 后端仍按这三个字段接收(接口中均为可选)。
signerName: signerType.value === 'FAMILY' ? signerName.value.trim() : patient.value.name, signerType: 'SELF',
signerRelation: signerType.value === 'FAMILY' ? signerRelation.value.trim() : '本人', signerName: patient.value.name,
signerRelation: '本人',
idempotencyKey: createTaskIdempotencyKey, idempotencyKey: createTaskIdempotencyKey,
}) })
@@ -750,13 +682,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
selectedVisit, selectedVisit,
selectedVisitId, selectedVisitId,
smsDestination, smsDestination,
staffAnswers,
manualFormFields,
signerType,
signerName,
signerRelation,
staffAnswerHasOption,
toggleStaffAnswerOption,
submit, submit,
submitting, submitting,
suggestionOpen, suggestionOpen,
+141 -80
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getAuthErrorMessage, getCaptcha } from '@/api/auth' import { getAuthErrorMessage, getCaptcha } from '@/api/auth'
import hospitalLogo from '@/assets/hospital-logo.png'
import type { CaptchaResponse, LoginRequest } from '@/types/auth' import type { CaptchaResponse, LoginRequest } from '@/types/auth'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
@@ -18,7 +19,6 @@ const captchaLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
const passwordChanged = route.query.passwordChanged === '1' const passwordChanged = route.query.passwordChanged === '1'
const passwordChangeRequested = computed(() => route.query.redirect === '/change-password')
async function loadCaptcha() { async function loadCaptcha() {
captchaLoading.value = true captchaLoading.value = true
@@ -42,13 +42,6 @@ function getRedirectPath() {
: '/workbench/home' : '/workbench/home'
} }
function requestPasswordChange() {
void router.replace({
name: 'login',
query: { ...route.query, redirect: '/change-password' },
})
}
async function handleLogin() { async function handleLogin() {
const normalizedUsername = username.value.trim() const normalizedUsername = username.value.trim()
@@ -103,29 +96,40 @@ onMounted(() => {
<template> <template>
<main class="login-page"> <main class="login-page">
<div class="login-visual" aria-hidden="true"></div>
<div class="login-shade" aria-hidden="true"></div>
<div class="login-caption">
<span class="login-badge">
<img :src="hospitalLogo" alt="新华医院" />
</span>
<div class="login-caption-text">
<b>上海交通大学医学院附属新华医院</b>
<span>Xinhua Hospital Affiliated To Shanghai Jiao Tong University School Of Medicine</span>
</div>
</div>
<form class="login-card" novalidate @submit.prevent="handleLogin"> <form class="login-card" novalidate @submit.prevent="handleLogin">
<div class="login-brand"> <div class="login-brand">
<div class="login-brand-icon" aria-hidden="true"> <div class="login-brand-icon" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none"> <svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path <path
d="M14 3v5h5M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z" d="M5.6 2.6v4.2M3.5 4.7h4.2"
stroke="#fff"
stroke-width="1.7"
stroke-linejoin="round"
/>
<path
d="m9 13.5 2.5 2.5 4-4.5"
stroke="#fff" stroke="#fff"
stroke-width="1.7" stroke-width="1.7"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M9.7 16.3l1.1-3.1 6.3-6.3a1.41 1.41 0 0 1 2 2l-6.3 6.3-3.1 1.1z" fill="#fff" />
<path d="M15.5 8.5l2 2" stroke="#9bd8e9" stroke-width="1.2" stroke-linecap="round" />
<path
d="M2.8 21.2c1.7 0 1.7-1.3 3.4-1.3s1.7 1.3 3.4 1.3 1.7-1.3 3.4-1.3 1.7 1.3 3.4 1.3 1.7-1.3 3.4-1.3"
stroke="#fff"
stroke-width="1.6"
stroke-linecap="round"
/> />
</svg> </svg>
</div> </div>
<div> <h1>医签通</h1>
<h1>医签通</h1>
<p>MEDISIGN · 患者电子签系统</p>
</div>
</div> </div>
<p v-if="passwordChanged" class="login-success" role="status"> <p v-if="passwordChanged" class="login-success" role="status">
@@ -154,7 +158,7 @@ onMounted(() => {
/> />
</div> </div>
<div v-if="captcha" class="login-field login-captcha"> <div class="login-field login-captcha">
<label for="login-captcha">验证码</label> <label for="login-captcha">验证码</label>
<div class="captcha-row"> <div class="captcha-row">
<input <input
@@ -173,7 +177,9 @@ onMounted(() => {
aria-label="刷新验证码" aria-label="刷新验证码"
@click="loadCaptcha" @click="loadCaptcha"
> >
<img :src="captcha.imageBase64" alt="图形验证码,点击刷新" /> <span v-if="captchaLoading" class="captcha-spinner" aria-hidden="true" />
<img v-else-if="captcha" :src="captcha.imageBase64" alt="图形验证码,点击刷新" />
<span v-else class="captcha-retry">点击重试</span>
</button> </button>
</div> </div>
</div> </div>
@@ -183,16 +189,9 @@ onMounted(() => {
<button class="login-submit" type="submit" :disabled="isSubmitting"> <button class="login-submit" type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '登录中…' : '登 录' }} {{ isSubmitting ? '登录中…' : '登 录' }}
</button> </button>
<p v-if="passwordChangeRequested" class="login-change-hint" role="status">
登录后将进入修改密码页面
</p>
<button class="login-change-link" type="button" @click="requestPasswordChange">
修改密码
</button>
</form> </form>
<p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p> <p class="login-footer">© 2026 上海交通大学医学院附属新华医院 · 医签通 MEDISIGN V1.1</p>
</main> </main>
</template> </template>
@@ -203,15 +202,84 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--login-gradient); background: #0e6e8c;
}
.login-visual {
position: absolute;
inset: 0;
background: url('@/assets/login-bg.jpg') center / cover no-repeat;
}
.login-shade {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgb(6 40 54 / 38%) 0%,
rgb(6 40 54 / 0%) 28%,
rgb(6 40 54 / 0%) 55%,
rgb(6 40 54 / 52%) 100%
);
}
.login-caption {
position: absolute;
top: 28px;
left: 34px;
z-index: 2;
display: flex;
gap: 12px;
align-items: center;
color: #fff;
text-shadow: 0 2px 12px rgb(0 0 0 / 50%);
}
.login-badge {
display: flex;
width: 48px;
height: 48px;
flex: none;
align-items: center;
justify-content: center;
padding: 3px;
background: rgb(255 255 255 / 95%);
border-radius: 50%;
box-shadow: 0 4px 16px rgb(6 40 54 / 40%);
}
.login-badge img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 50%;
}
.login-caption-text b {
display: block;
font-size: 19px;
font-weight: 700;
letter-spacing: 1.5px;
}
.login-caption-text span {
display: block;
margin-top: 5px;
font-size: 10.5px;
letter-spacing: 0.4px;
opacity: 0.85;
} }
.login-card { .login-card {
position: relative;
z-index: 2;
width: 380px; width: 380px;
padding: 30px 36px; padding: 34px 34px 30px;
background: #fff; background: #fff;
border-radius: 14px; border-radius: 14px;
box-shadow: var(--sh-login); box-shadow:
0 24px 64px rgb(6 40 54 / 38%),
0 4px 16px rgb(6 40 54 / 22%);
} }
.login-brand { .login-brand {
@@ -219,13 +287,14 @@ onMounted(() => {
gap: 12px; gap: 12px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 8px; margin-bottom: 16px;
} }
.login-brand-icon { .login-brand-icon {
display: grid; display: grid;
width: 46px; width: 46px;
height: 46px; height: 46px;
flex: none;
place-items: center; place-items: center;
border-radius: 12px; border-radius: 12px;
background: linear-gradient(135deg, var(--brand), #18a0c0); background: linear-gradient(135deg, var(--brand), #18a0c0);
@@ -238,22 +307,8 @@ onMounted(() => {
letter-spacing: 1px; letter-spacing: 1px;
} }
.login-brand p {
margin: 2px 0 0;
color: #8aa7b3;
font-size: 11px;
letter-spacing: 2px;
}
.login-subtitle {
margin: 0 0 20px;
color: var(--mut);
font-size: 12px;
text-align: center;
}
.login-field { .login-field {
margin-bottom: 10px; margin-bottom: 14px;
} }
.login-field label { .login-field label {
@@ -307,7 +362,6 @@ onMounted(() => {
.captcha-image-button:disabled { .captcha-image-button:disabled {
cursor: wait; cursor: wait;
opacity: 0.6;
} }
.captcha-image-button img { .captcha-image-button img {
@@ -335,8 +389,8 @@ onMounted(() => {
.login-submit { .login-submit {
width: 100%; width: 100%;
margin-top: 6px; margin-top: 10px;
padding: 9px; padding: 10px;
color: #fff; color: #fff;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@@ -353,51 +407,58 @@ onMounted(() => {
opacity: 0.65; opacity: 0.65;
} }
.login-tip { .captcha-spinner {
margin: 12px 0 0; width: 16px;
padding: 7px; height: 16px;
color: var(--mut); border: 2px solid var(--line);
font-size: 12px; border-top-color: var(--brand);
text-align: center; border-radius: 50%;
background: var(--brand-l); animation: captcha-spin 0.7s linear infinite;
border-radius: 8px;
} }
.login-change-hint { .captcha-retry {
margin: 8px 0 0;
color: var(--brand); color: var(--brand);
font-size: 12px; font-size: 12px;
text-align: center;
} }
.login-change-link { @keyframes captcha-spin {
display: block; to {
padding: 7px 12px; transform: rotate(360deg);
margin: 5px auto 0; }
color: var(--brand);
font-size: 12px;
background: transparent;
border: 0;
}
.login-change-link:hover {
text-decoration: underline;
} }
.login-footer { .login-footer {
position: absolute; position: absolute;
bottom: 18px; bottom: 16px;
z-index: 2;
width: 100%; width: 100%;
margin: 0; margin: 0;
color: rgb(255 255 255 / 55%); color: rgb(255 255 255 / 82%);
font-size: 12px; font-size: 11.5px;
text-align: center; text-align: center;
text-shadow: 0 1px 6px rgb(0 0 0 / 45%);
}
@media (max-width: 820px) {
.login-caption-text span {
display: none;
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {
.login-caption {
top: 18px;
left: 18px;
}
.login-caption-text b {
font-size: 16px;
letter-spacing: 1px;
}
.login-card { .login-card {
width: calc(100% - 32px); width: calc(100% - 32px);
padding: 32px 24px; padding: 28px 22px;
} }
} }
</style> </style>
@@ -21,15 +21,30 @@ const periodOptions: Array<{ label: string; value: HomeRankingPeriod }> = [
const rankColors = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f'] 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) { 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) { function getRankColor(index: number) {
return rankColors[index % rankColors.length] 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)`
}
</script> </script>
<template> <template>
@@ -65,7 +80,7 @@ function getRankColor(index: number) {
<span class="ranking-bar-track"> <span class="ranking-bar-track">
<span <span
class="ranking-bar" class="ranking-bar"
:style="{ width: getBarWidth(row), background: getRankColor(index) }" :style="{ width: getBarWidth(row), background: getBarBackground(index) }"
/> />
</span> </span>
<strong class="ranking-count" :style="{ color: getRankColor(index) }"> <strong class="ranking-count" :style="{ color: getRankColor(index) }">
@@ -73,15 +88,16 @@ function getRankColor(index: number) {
</strong> </strong>
</div> </div>
<div class="ranking-axis" aria-hidden="true"> <div class="ranking-axis" aria-hidden="true">
<span>0</span> <span v-for="tick in axisTicks" :key="tick">{{ tick }}</span>
<span>{{ Math.ceil(maxCount / 4) }}</span>
<span>{{ Math.ceil(maxCount / 2) }}</span>
<span>{{ Math.ceil((maxCount * 3) / 4) }}</span>
<span>{{ maxCount }}</span>
</div> </div>
</div> </div>
<div v-else class="ranking-empty">暂无排名数据</div> <div v-else class="ranking-empty">
<div class="empty-icon" aria-hidden="true">📊</div>
<p>暂无排名数据</p>
<p class="empty-desc">文档被签署后将按近7/14/30天统计签署排名</p>
<button type="button" class="empty-action" @click="emit('manage')">前往文档库发布模板</button>
</div>
</section> </section>
</template> </template>
@@ -200,7 +216,6 @@ function getRankColor(index: number) {
height: 100%; height: 100%;
min-width: 2px; min-width: 2px;
border-radius: 4px; border-radius: 4px;
opacity: 0.82;
} }
.ranking-count { .ranking-count {
@@ -219,11 +234,41 @@ function getRankColor(index: number) {
} }
.ranking-empty { .ranking-empty {
display: grid; display: flex;
min-height: 210px; min-height: 210px;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--mut); color: var(--mut);
font-size: 13px; 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) { @media (max-width: 900px) {
@@ -132,6 +132,11 @@ const emit = defineEmits<{
background: var(--warn-l); background: var(--warn-l);
} }
.metric-icon--info {
color: var(--info);
background: var(--info-l);
}
.metric-icon--danger { .metric-icon--danger {
color: var(--err); color: var(--err);
background: var(--err-l); background: var(--err-l);
@@ -1,37 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { HomeRankingPeriod, HomeTrendPoint } from '../types' import type { HomeTrendPoint } from '../types'
const props = defineProps<{ const props = defineProps<{
points: HomeTrendPoint[] points: HomeTrendPoint[]
period: HomeRankingPeriod
}>() }>()
const chartWidth = 620 const emit = defineEmits<{
const chartHeight = 210 create: []
const chartPadding = { }>()
top: 16,
right: 18,
bottom: 34,
left: 42,
}
const plotWidth = chartWidth - chartPadding.left - chartPadding.right /*
const plotHeight = chartHeight - chartPadding.top - chartPadding.bottom * 几何照抄原型 svgLine():`viewBox 560×190` + 四边留白 34、
* 横向等分虚线(图上共 4 条)、**没有 Y 轴刻度数字**(数值直接标在点上)。
*
* 尺寸必须和原型一致:SVG 只给 `width:100%`、**不给高度**,
* 高度由 viewBox 比例算出来(560:190)。写死高度会让默认的 `xMidYMid meet`
* 把图形等比缩到中间 —— 实测 833px 宽的卡片里图形只占 620px,左右各空 16.9%
* (原型只空 6.1%),整块图又矮又窄。
*/
const chartWidth = 560
const chartHeight = 190
const chartPadding = 34
const chartMax = computed(() => { const plotWidth = chartWidth - chartPadding * 2
const highest = Math.max(...props.points.map((point) => point.value), 4) const plotHeight = chartHeight - chartPadding * 2
return Math.ceil((highest * 1.2) / 4) * 4
}) /** 原型 max = Math.max(...data, 4) * 1.2;没有 Y 轴刻度就不需要取整到 4 的倍数 */
const chartMax = computed(() => Math.max(...props.points.map((point) => point.value), 4) * 1.2)
const chartPoints = computed(() => { const chartPoints = computed(() => {
const count = props.points.length const count = props.points.length
return props.points.map((point, index) => ({ return props.points.map((point, index) => ({
...point, ...point,
x: count > 1 ? chartPadding.left + (plotWidth * index) / (count - 1) : chartWidth / 2, x: count > 1 ? chartPadding + (plotWidth * index) / (count - 1) : chartWidth / 2,
y: chartPadding.top + plotHeight - (point.value / chartMax.value) * plotHeight, y: chartPadding + plotHeight - (point.value / chartMax.value) * plotHeight,
})) }))
}) })
@@ -50,82 +55,100 @@ const areaPath = computed(() => {
return '' return ''
} }
return `${linePath.value} L${lastPoint.x.toFixed(1)} ${chartPadding.top + plotHeight} L${firstPoint.x.toFixed(1)} ${chartPadding.top + plotHeight} Z` const baseline = chartPadding + plotHeight
return `${linePath.value} L${lastPoint.x.toFixed(1)} ${baseline} L${firstPoint.x.toFixed(1)} ${baseline} Z`
}) })
/** 原型 4 条横向虚线的 y 值(等分,不含刻度数字) */
const gridLines = computed(() => const gridLines = computed(() =>
[0, 0.5, 1].map((ratio) => ({ [0, 1, 2, 3].map((index) => chartPadding + (plotHeight / 3) * index),
y: chartPadding.top + plotHeight * ratio,
label: Math.round(chartMax.value * (1 - ratio)),
})),
) )
/**
* 是否真的有数据。
*
* 不能用 `points.length` 判:后端对一个没有任何签署记录的院区**照样返回整段 0 点**
* (2026-09-30 实测东院区 period=7 → 7 点全 0),按长度判会画出一条贴底的直线,
* 而原型在没有数据时显示的是「📈 暂无签署数据 + 发起入口」空态,同页的待办/排名也都是空态。
*/
const hasData = computed(() => props.points.some((point) => point.value > 0))
</script> </script>
<template> <template>
<section class="home-panel trend-panel"> <section class="home-panel trend-panel">
<div class="panel-heading"> <div class="panel-heading">
<h2>近{{ period }}天签署趋势</h2> <h2>近一周签署客户趋势</h2>
<span>单位:单</span> <span>单位:人次</span>
</div> </div>
<div v-if="points.length" class="trend-chart-wrap"> <div v-if="hasData" class="trend-chart-wrap">
<svg <svg class="trend-chart" viewBox="0 0 560 190" role="img" aria-label="近一周签署客户趋势图">
class="trend-chart"
viewBox="0 0 620 210"
role="img"
:aria-label="'近' + period + '天签署趋势图'"
>
<defs> <defs>
<linearGradient id="home-trend-area" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="home-trend-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0e6e8c" stop-opacity="0.22" /> <stop offset="0" stop-color="#1487a5" stop-opacity="0.28" />
<stop offset="1" stop-color="#0e6e8c" stop-opacity="0.02" /> <stop offset="1" stop-color="#1487a5" stop-opacity="0" />
</linearGradient> </linearGradient>
</defs> </defs>
<g class="trend-grid"> <g class="trend-grid">
<line <line
v-for="line in gridLines" v-for="y in gridLines"
:key="line.y" :key="y"
:x1="chartPadding.left" :x1="chartPadding"
:y1="line.y" :y1="y"
:x2="chartWidth - chartPadding.right" :x2="chartWidth - chartPadding"
:y2="line.y" :y2="y"
/> />
<text
v-for="line in gridLines"
:key="`label-${line.y}`"
:x="chartPadding.left - 10"
:y="line.y + 4"
text-anchor="end"
>
{{ line.label }}
</text>
</g> </g>
<path class="trend-area" :d="areaPath" /> <path class="trend-area" :d="areaPath" />
<path class="trend-line" :d="linePath" /> <path class="trend-line" :d="linePath" />
<g v-for="point in chartPoints" :key="point.label" class="trend-point"> <g v-for="point in chartPoints" :key="point.label" class="trend-point">
<circle :cx="point.x" :cy="point.y" r="4" /> <circle :cx="point.x" :cy="point.y" r="4.5" />
<title>{{ point.label }}:{{ point.value }} 人次</title> <title>{{ point.label }}:{{ point.value }} 人次</title>
<text :x="point.x" :y="chartHeight - 9" text-anchor="middle">{{ point.label }}</text> <text class="trend-point__value" :x="point.x" :y="point.y - 11" text-anchor="middle">
{{ point.value }}
</text>
<text class="trend-point__label" :x="point.x" :y="chartHeight - 12" text-anchor="middle">
{{ point.label }}
</text>
</g> </g>
</svg> </svg>
</div> </div>
<div v-else class="trend-empty">暂无趋势数据</div> <div v-else class="trend-empty">
<div class="empty-icon" aria-hidden="true">📈</div>
<p>暂无签署数据</p>
<p class="empty-desc">发起首个签署任务后,这里将展示近 7 日签署趋势</p>
<button type="button" class="empty-action" @click="emit('create')">+ 发起签署任务</button>
</div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.home-panel { .home-panel {
display: flex;
min-width: 0; min-width: 0;
flex-direction: column;
padding: 16px 18px; padding: 16px 18px;
background: var(--card); background: var(--card);
border-radius: var(--r); border-radius: var(--r);
box-shadow: var(--sh); box-shadow: var(--sh);
} }
/*
* 趋势卡与「我的待办」同行,行高由更高的待办卡决定(卡片默认 stretch)。
* 图的高度是宽度按 560:190 算出来的,比卡片矮 → 默认会在下方留一条空白。
* 按使用要求**默认把图压到卡片底部**(`margin-top: auto` 吃掉剩余空间),
* 标题仍在顶部;留白集中在标题与图之间。
*/
.trend-panel .trend-chart-wrap,
.trend-panel .trend-empty {
margin-top: auto;
}
.panel-heading { .panel-heading {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -151,24 +174,24 @@ const gridLines = computed(() =>
min-height: 190px; min-height: 190px;
} }
/*
* 只给宽度、**不给高度**(原型就是 `style="width:100%"`):
* 高度由 viewBox 的 560:190 比例算出来,图才会铺满卡片宽度。
* 写死 height 会触发默认的 preserveAspectRatio="xMidYMid meet"——
* 图形按高度等比缩放后居中,两侧各留一大片空白。
*/
.trend-chart { .trend-chart {
display: block; display: block;
width: 100%; width: 100%;
height: 210px; height: auto;
overflow: visible; overflow: visible;
} }
.trend-grid line { .trend-grid line {
stroke: #eef4f6; stroke: #e6eef2;
stroke-dasharray: 3 4; stroke-dasharray: 3 4;
} }
.trend-grid text,
.trend-point text {
fill: var(--mut);
font-size: 10px;
}
.trend-area { .trend-area {
fill: url('#home-trend-area'); fill: url('#home-trend-area');
} }
@@ -178,24 +201,61 @@ const gridLines = computed(() =>
stroke: var(--brand); stroke: var(--brand);
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
stroke-width: 2.5; stroke-width: 2.6;
}
/* 数值直接标在点上(原型 svgLine 的 <text>,加粗深色),所以不需要 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 { .trend-point circle {
fill: #fff; fill: #fff;
stroke: var(--brand); stroke: var(--brand);
stroke-width: 2; stroke-width: 2.2;
}
.trend-point text {
dominant-baseline: auto;
} }
.trend-empty { .trend-empty {
display: grid; display: flex;
min-height: 210px; min-height: 208px;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--mut); color: var(--mut);
font-size: 13px; 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);
} }
</style> </style>
@@ -147,18 +147,20 @@ function getHint(item: HomeTodoItem) {
.todo-list { .todo-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; /* 原型 .titem 是 margin-bottom:10px */
gap: 10px;
} }
.todo-item { .todo-item {
display: block; display: block;
width: 100%; width: 100%;
padding: 9px 11px; /* 原型 .titem:padding 11px 13px + 圆角 10px */
padding: 11px 13px;
color: var(--ink); color: var(--ink);
text-align: left; text-align: left;
background: #fff; background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: 10px;
transition: transition:
border-color 0.15s ease, border-color 0.15s ease,
background 0.15s ease; background 0.15s ease;
@@ -195,8 +197,9 @@ function getHint(item: HomeTodoItem) {
.todo-status { .todo-status {
flex-shrink: 0; flex-shrink: 0;
padding: 2px 8px; /* 原型 .tag:padding 2px 10px / 11.5px / 圆角 20px */
font-size: 11px; padding: 2px 10px;
font-size: 11.5px;
font-weight: 700; font-weight: 700;
border-radius: 20px; border-radius: 20px;
} }
@@ -227,15 +230,18 @@ function getHint(item: HomeTodoItem) {
background: var(--ok-l); background: var(--ok-l);
} }
/* 原型 .tg-void */
.todo-status--muted { .todo-status--muted {
color: var(--mut); color: #8a9aa3;
background: #eef3f5; background: #eceef0;
} }
.todo-row--detail { .todo-row--detail {
margin-top: 5px; margin-top: 5px;
gap: 12px;
flex-wrap: wrap;
color: var(--mut); color: var(--mut);
font-size: 12px; font-size: 12.5px;
} }
.todo-document { .todo-document {
@@ -247,7 +253,6 @@ function getHint(item: HomeTodoItem) {
.todo-hint { .todo-hint {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
white-space: nowrap; white-space: nowrap;
} }
+139 -5
View File
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router'
import { getWorkbenchOverview } from '@/api/workbench/home' import { getWorkbenchOverview } from '@/api/workbench/home'
import type { WorkbenchCampus, WorkbenchOverviewResponse } from '@/api/workbench/types' import type { WorkbenchCampus, WorkbenchOverviewResponse } from '@/api/workbench/types'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import DocumentRankingPanel from './components/DocumentRankingPanel.vue' import DocumentRankingPanel from './components/DocumentRankingPanel.vue'
import HomeMetricCard from './components/HomeMetricCard.vue' import HomeMetricCard from './components/HomeMetricCard.vue'
@@ -22,11 +23,14 @@ import type {
const router = useRouter() const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const loginStore = useLoginStore()
const loading = ref(true) const loading = ref(true)
const loadFailed = ref(false) const loadFailed = ref(false)
const overview = ref<WorkbenchOverviewResponse | null>(null) const overview = ref<WorkbenchOverviewResponse | null>(null)
const rankingPeriod = ref<HomeRankingPeriod>(14) const rankingPeriod = ref<HomeRankingPeriod>(14)
/** 数据快照时间:原型在渲染时把当前时刻写进欢迎条,取到数据那一刻即为快照时刻 */
const snapshotAt = ref<Date | null>(null)
let overviewRequestId = 0 let overviewRequestId = 0
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus) const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
@@ -56,8 +60,9 @@ const metrics = computed<HomeMetricCardData[]>(() => {
key: 'pending', key: 'pending',
label: '待患者签署', label: '待患者签署',
value: summary?.pendingPatientSigning ?? 0, value: summary?.pendingPatientSigning ?? 0,
tone: 'brand', // 原型这张卡的图标是 --info-l 底 + #3a7bd5 笔画,不是品牌色
detail: '含手写板 / 短信 · 点击查看', tone: 'info',
detail: '含手写板 / 短信链接 / Pad · 点击查看',
detailTone: 'muted', detailTone: 'muted',
}, },
{ {
@@ -73,13 +78,73 @@ const metrics = computed<HomeMetricCardData[]>(() => {
label: '可用文档模板(份)', label: '可用文档模板(份)',
value: summary?.availableTemplates ?? 0, value: summary?.availableTemplates ?? 0,
tone: 'brand', tone: 'brand',
detail: `覆盖 ${summary?.coveredDepartments ?? 0} 个科室 · 点击查看`, // 原型:没有可用模板时副文案换成引导去文档库,拼「覆盖 0 个科室」会读不通
detail: summary?.availableTemplates
? `覆盖 ${summary?.coveredDepartments ?? 0} 个科室 · 点击查看`
: '暂无可用模板 · 去文档库发布',
detailTone: 'muted', detailTone: 'muted',
}, },
] ]
}) })
const trendPoints = computed<HomeTrendPoint[]>(() => 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<HomeTrendPoint[]>(() =>
(overview.value?.trend ?? []).slice(-HOME_TREND_DAYS),
)
const todoItems = computed<HomeTodoItem[]>(() => overview.value?.todos ?? []) const todoItems = computed<HomeTodoItem[]>(() => overview.value?.todos ?? [])
const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? []) const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? [])
@@ -106,6 +171,7 @@ async function loadOverview() {
} }
overview.value = nextOverview overview.value = nextOverview
snapshotAt.value = new Date()
} catch { } catch {
if (requestId !== overviewRequestId) { if (requestId !== overviewRequestId) {
return return
@@ -154,6 +220,11 @@ function handlePeriodChange(period: HomeRankingPeriod) {
rankingPeriod.value = period rankingPeriod.value = period
} }
/** 趋势图空态的原型入口是「+ 发起签署任务」(工作台 + 打开新增弹窗),仓库里只跳工作台 */
function handleCreateTask() {
goToSigning()
}
function retryLoad() { function retryLoad() {
void loadOverview() void loadOverview()
} }
@@ -169,6 +240,20 @@ onMounted(() => {
<template> <template>
<section class="home-page"> <section class="home-page">
<div class="welcome">
<div>
<b class="welcome-greeting">{{ greeting }}</b>
<span class="welcome-date">{{ greetingDate }}</span>
<div class="welcome-todo">
今天有 <b>{{ pendingTodoCount }}</b> 件签署待办需要处理,祝工作顺利。
</div>
</div>
<span class="welcome-spacer" />
<span class="welcome-snapshot">
数据快照 <b>{{ snapshotTime }}</b> · 统计口径:签署完成时间(Asia/Shanghai)
</span>
</div>
<div v-if="loading" class="home-loading" aria-label="正在加载首页"> <div v-if="loading" class="home-loading" aria-label="正在加载首页">
<div class="metric-loading-grid"> <div class="metric-loading-grid">
<span v-for="index in 4" :key="index" /> <span v-for="index in 4" :key="index" />
@@ -197,7 +282,7 @@ onMounted(() => {
</div> </div>
<div class="overview-grid"> <div class="overview-grid">
<SigningTrendPanel :points="trendPoints" :period="rankingPeriod" /> <SigningTrendPanel :points="trendPoints" @create="handleCreateTask" />
<TodoPanel :items="todoItems" @select="handleTodoSelect" @view-all="goToSigning()" /> <TodoPanel :items="todoItems" @select="handleTodoSelect" @view-all="goToSigning()" />
</div> </div>
@@ -218,6 +303,55 @@ onMounted(() => {
margin: 0 auto; margin: 0 auto;
} }
.welcome {
display: flex;
gap: 14px;
align-items: center;
padding: 14px 20px;
margin-bottom: 14px;
background: linear-gradient(120deg, #fff, #f2f9fb);
border: 1px solid var(--line);
border-radius: 12px;
}
/*
* 原型 `.welcome b{font-size:16px}` 会把问候语、待办数、快照时刻一起放大 ——
* 这里是照抄,不做「只给问候语放大」的收敛,否则和原型并排比对不上。
*/
.welcome b {
font-size: 16px;
font-weight: 700;
}
.welcome-greeting {
color: var(--ink);
}
.welcome-date {
margin-left: 10px;
color: var(--mut);
font-size: 12px;
}
.welcome-todo {
margin-top: 2px;
color: var(--mut);
font-size: 12px;
}
.welcome-todo b {
color: var(--brand);
}
.welcome-spacer {
flex: 1;
}
.welcome-snapshot {
color: var(--mut);
font-size: 12px;
}
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1,6 +1,6 @@
import type { SigningMethod } from '@/api/workbench/types' import type { SigningMethod } from '@/api/workbench/types'
export type HomeMetricTone = 'brand' | 'success' | 'warning' | 'danger' export type HomeMetricTone = 'brand' | 'success' | 'warning' | 'danger' | 'info'
export type HomeMetricKey = 'todaySigned' | 'pending' | 'overdue' | 'templates' export type HomeMetricKey = 'todaySigned' | 'pending' | 'overdue' | 'templates'
@@ -453,16 +453,6 @@ onMounted(() => {
<template> <template>
<section class="records-page"> <section class="records-page">
<div class="page-intro">
<div>
<h1>签署记录查询</h1>
<p>按条件检索全量历史签署记录;工作台只保留当天签署操作,历史记录统一在这里查询。</p>
</div>
<div class="page-intro__meta">
<span>共 {{ total }} 条记录</span>
</div>
</div>
<SigningRecordFilterBar <SigningRecordFilterBar
:filter="filter" :filter="filter"
:campuses="campusOptions" :campuses="campusOptions"
@@ -528,43 +518,6 @@ onMounted(() => {
margin: 0 auto; margin: 0 auto;
} }
.page-intro {
display: flex;
gap: 20px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 14px;
}
.page-intro h1 {
margin: 0;
color: var(--ink);
font-size: 19px;
letter-spacing: 0.5px;
}
.page-intro p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12.5px;
}
.page-intro__meta {
display: flex;
gap: 8px;
align-items: center;
color: var(--mut);
font-size: 12px;
white-space: nowrap;
}
.page-intro__meta span {
padding: 5px 9px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 6px;
}
.record-card { .record-card {
padding: 14px 18px 12px; padding: 14px 18px 12px;
background: var(--card); background: var(--card);
@@ -595,12 +548,4 @@ onMounted(() => {
font-size: 11.5px; font-size: 11.5px;
line-height: 1.7; line-height: 1.7;
} }
@media (max-width: 680px) {
.page-intro {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
}
</style> </style>