merge: 合并远程更新并保留签名与报表接入
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getAuthErrorMessage, getCaptcha } from '@/api/auth'
|
||||
import hospitalLogo from '@/assets/hospital-logo.png'
|
||||
import type { CaptchaResponse, LoginRequest } from '@/types/auth'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
|
||||
@@ -18,7 +19,6 @@ const captchaLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const passwordChanged = route.query.passwordChanged === '1'
|
||||
const passwordChangeRequested = computed(() => route.query.redirect === '/change-password')
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true
|
||||
@@ -42,13 +42,6 @@ function getRedirectPath() {
|
||||
: '/workbench/home'
|
||||
}
|
||||
|
||||
function requestPasswordChange() {
|
||||
void router.replace({
|
||||
name: 'login',
|
||||
query: { ...route.query, redirect: '/change-password' },
|
||||
})
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const normalizedUsername = username.value.trim()
|
||||
|
||||
@@ -103,29 +96,40 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="login-brand">
|
||||
<div class="login-brand-icon" aria-hidden="true">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||
<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"
|
||||
stroke="#fff"
|
||||
stroke-width="1.7"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m9 13.5 2.5 2.5 4-4.5"
|
||||
d="M5.6 2.6v4.2M3.5 4.7h4.2"
|
||||
stroke="#fff"
|
||||
stroke-width="1.7"
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
<h1>医签通</h1>
|
||||
<p>MEDISIGN · 患者电子签系统</p>
|
||||
</div>
|
||||
<h1>医签通</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="passwordChanged" class="login-success" role="status">
|
||||
@@ -154,7 +158,7 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="captcha" class="login-field login-captcha">
|
||||
<div class="login-field login-captcha">
|
||||
<label for="login-captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<input
|
||||
@@ -173,7 +177,9 @@ onMounted(() => {
|
||||
aria-label="刷新验证码"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,16 +189,9 @@ onMounted(() => {
|
||||
<button class="login-submit" type="submit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '登录中…' : '登 录' }}
|
||||
</button>
|
||||
|
||||
<p v-if="passwordChangeRequested" class="login-change-hint" role="status">
|
||||
登录后将进入修改密码页面
|
||||
</p>
|
||||
<button class="login-change-link" type="button" @click="requestPasswordChange">
|
||||
修改密码
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-footer">© 2026 医签通 MEDISIGN · 患者电子签署系统 V1.1 原型</p>
|
||||
<p class="login-footer">© 2026 上海交通大学医学院附属新华医院 · 医签通 MEDISIGN V1.1</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -203,15 +202,84 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: 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 {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 380px;
|
||||
padding: 30px 36px;
|
||||
padding: 34px 34px 30px;
|
||||
background: #fff;
|
||||
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 {
|
||||
@@ -219,13 +287,14 @@ onMounted(() => {
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-brand-icon {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--brand), #18a0c0);
|
||||
@@ -238,22 +307,8 @@ onMounted(() => {
|
||||
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 {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.login-field label {
|
||||
@@ -307,7 +362,6 @@ onMounted(() => {
|
||||
|
||||
.captcha-image-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.captcha-image-button img {
|
||||
@@ -335,8 +389,8 @@ onMounted(() => {
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 9px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
@@ -353,51 +407,58 @@ onMounted(() => {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.login-tip {
|
||||
margin: 12px 0 0;
|
||||
padding: 7px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
background: var(--brand-l);
|
||||
border-radius: 8px;
|
||||
.captcha-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--line);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: captcha-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.login-change-hint {
|
||||
margin: 8px 0 0;
|
||||
.captcha-retry {
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-change-link {
|
||||
display: block;
|
||||
padding: 7px 12px;
|
||||
margin: 5px auto 0;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.login-change-link:hover {
|
||||
text-decoration: underline;
|
||||
@keyframes captcha-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
bottom: 16px;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: rgb(255 255 255 / 55%);
|
||||
font-size: 12px;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
font-size: 11.5px;
|
||||
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) {
|
||||
.login-caption {
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.login-caption-text b {
|
||||
font-size: 16px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: calc(100% - 32px);
|
||||
padding: 32px 24px;
|
||||
padding: 28px 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,7 +80,7 @@ function getRankColor(index: number) {
|
||||
<span class="ranking-bar-track">
|
||||
<span
|
||||
class="ranking-bar"
|
||||
:style="{ width: getBarWidth(row), background: getRankColor(index) }"
|
||||
:style="{ width: getBarWidth(row), background: getBarBackground(index) }"
|
||||
/>
|
||||
</span>
|
||||
<strong class="ranking-count" :style="{ color: getRankColor(index) }">
|
||||
@@ -73,15 +88,16 @@ function getRankColor(index: number) {
|
||||
</strong>
|
||||
</div>
|
||||
<div class="ranking-axis" aria-hidden="true">
|
||||
<span>0</span>
|
||||
<span>{{ Math.ceil(maxCount / 4) }}</span>
|
||||
<span>{{ Math.ceil(maxCount / 2) }}</span>
|
||||
<span>{{ Math.ceil((maxCount * 3) / 4) }}</span>
|
||||
<span>{{ maxCount }}</span>
|
||||
<span v-for="tick in axisTicks" :key="tick">{{ tick }}</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { HomeRankingPeriod, HomeTrendPoint } from '../types'
|
||||
import type { HomeTrendPoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: HomeTrendPoint[]
|
||||
period: HomeRankingPeriod
|
||||
}>()
|
||||
|
||||
const chartWidth = 620
|
||||
const chartHeight = 210
|
||||
const chartPadding = {
|
||||
top: 16,
|
||||
right: 18,
|
||||
bottom: 34,
|
||||
left: 42,
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
create: []
|
||||
}>()
|
||||
|
||||
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 highest = Math.max(...props.points.map((point) => point.value), 4)
|
||||
return Math.ceil((highest * 1.2) / 4) * 4
|
||||
})
|
||||
const plotWidth = chartWidth - chartPadding * 2
|
||||
const plotHeight = chartHeight - chartPadding * 2
|
||||
|
||||
/** 原型 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 count = props.points.length
|
||||
|
||||
return props.points.map((point, index) => ({
|
||||
...point,
|
||||
x: count > 1 ? chartPadding.left + (plotWidth * index) / (count - 1) : chartWidth / 2,
|
||||
y: chartPadding.top + plotHeight - (point.value / chartMax.value) * plotHeight,
|
||||
x: count > 1 ? chartPadding + (plotWidth * index) / (count - 1) : chartWidth / 2,
|
||||
y: chartPadding + plotHeight - (point.value / chartMax.value) * plotHeight,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -50,82 +55,100 @@ const areaPath = computed(() => {
|
||||
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(() =>
|
||||
[0, 0.5, 1].map((ratio) => ({
|
||||
y: chartPadding.top + plotHeight * ratio,
|
||||
label: Math.round(chartMax.value * (1 - ratio)),
|
||||
})),
|
||||
[0, 1, 2, 3].map((index) => chartPadding + (plotHeight / 3) * index),
|
||||
)
|
||||
|
||||
/**
|
||||
* 是否真的有数据。
|
||||
*
|
||||
* 不能用 `points.length` 判:后端对一个没有任何签署记录的院区**照样返回整段 0 点**
|
||||
* (2026-09-30 实测东院区 period=7 → 7 点全 0),按长度判会画出一条贴底的直线,
|
||||
* 而原型在没有数据时显示的是「📈 暂无签署数据 + 发起入口」空态,同页的待办/排名也都是空态。
|
||||
*/
|
||||
const hasData = computed(() => props.points.some((point) => point.value > 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="home-panel trend-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>近{{ period }}天签署趋势</h2>
|
||||
<span>单位:单</span>
|
||||
<h2>近一周签署客户趋势</h2>
|
||||
<span>单位:人次</span>
|
||||
</div>
|
||||
|
||||
<div v-if="points.length" class="trend-chart-wrap">
|
||||
<svg
|
||||
class="trend-chart"
|
||||
viewBox="0 0 620 210"
|
||||
role="img"
|
||||
:aria-label="'近' + period + '天签署趋势图'"
|
||||
>
|
||||
<div v-if="hasData" class="trend-chart-wrap">
|
||||
<svg class="trend-chart" viewBox="0 0 560 190" role="img" aria-label="近一周签署客户趋势图">
|
||||
<defs>
|
||||
<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="1" stop-color="#0e6e8c" stop-opacity="0.02" />
|
||||
<stop offset="0" stop-color="#1487a5" stop-opacity="0.28" />
|
||||
<stop offset="1" stop-color="#1487a5" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g class="trend-grid">
|
||||
<line
|
||||
v-for="line in gridLines"
|
||||
:key="line.y"
|
||||
:x1="chartPadding.left"
|
||||
:y1="line.y"
|
||||
:x2="chartWidth - chartPadding.right"
|
||||
:y2="line.y"
|
||||
v-for="y in gridLines"
|
||||
:key="y"
|
||||
:x1="chartPadding"
|
||||
:y1="y"
|
||||
:x2="chartWidth - chartPadding"
|
||||
: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>
|
||||
|
||||
<path class="trend-area" :d="areaPath" />
|
||||
<path class="trend-line" :d="linePath" />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</svg>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
/*
|
||||
* 趋势卡与「我的待办」同行,行高由更高的待办卡决定(卡片默认 stretch)。
|
||||
* 图的高度是宽度按 560:190 算出来的,比卡片矮 → 默认会在下方留一条空白。
|
||||
* 按使用要求**默认把图压到卡片底部**(`margin-top: auto` 吃掉剩余空间),
|
||||
* 标题仍在顶部;留白集中在标题与图之间。
|
||||
*/
|
||||
.trend-panel .trend-chart-wrap,
|
||||
.trend-panel .trend-empty {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -151,24 +174,24 @@ const gridLines = computed(() =>
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 只给宽度、**不给高度**(原型就是 `style="width:100%"`):
|
||||
* 高度由 viewBox 的 560:190 比例算出来,图才会铺满卡片宽度。
|
||||
* 写死 height 会触发默认的 preserveAspectRatio="xMidYMid meet"——
|
||||
* 图形按高度等比缩放后居中,两侧各留一大片空白。
|
||||
*/
|
||||
.trend-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.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 +201,61 @@ const gridLines = computed(() =>
|
||||
stroke: var(--brand);
|
||||
stroke-linecap: 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 {
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WorkbenchOverviewResponse | null>(null)
|
||||
const rankingPeriod = ref<HomeRankingPeriod>(14)
|
||||
/** 数据快照时间:原型在渲染时把当前时刻写进欢迎条,取到数据那一刻即为快照时刻 */
|
||||
const snapshotAt = ref<Date | null>(null)
|
||||
let overviewRequestId = 0
|
||||
|
||||
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
|
||||
@@ -56,8 +60,9 @@ const metrics = computed<HomeMetricCardData[]>(() => {
|
||||
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<HomeMetricCardData[]>(() => {
|
||||
label: '可用文档模板(份)',
|
||||
value: summary?.availableTemplates ?? 0,
|
||||
tone: 'brand',
|
||||
detail: `覆盖 ${summary?.coveredDepartments ?? 0} 个科室 · 点击查看`,
|
||||
// 原型:没有可用模板时副文案换成引导去文档库,拼「覆盖 0 个科室」会读不通
|
||||
detail: summary?.availableTemplates
|
||||
? `覆盖 ${summary?.coveredDepartments ?? 0} 个科室 · 点击查看`
|
||||
: '暂无可用模板 · 去文档库发布',
|
||||
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 rankingRows = computed<HomeDocumentRanking[]>(() => 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(() => {
|
||||
|
||||
<template>
|
||||
<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 class="metric-loading-grid">
|
||||
<span v-for="index in 4" :key="index" />
|
||||
@@ -197,7 +282,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<SigningTrendPanel :points="trendPoints" :period="rankingPeriod" />
|
||||
<SigningTrendPanel :points="trendPoints" @create="handleCreateTask" />
|
||||
<TodoPanel :items="todoItems" @select="handleTodoSelect" @view-all="goToSigning()" />
|
||||
</div>
|
||||
|
||||
@@ -218,6 +303,55 @@ onMounted(() => {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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'
|
||||
|
||||
|
||||
@@ -453,16 +453,6 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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
|
||||
:filter="filter"
|
||||
:campuses="campusOptions"
|
||||
@@ -528,43 +518,6 @@ onMounted(() => {
|
||||
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 {
|
||||
padding: 14px 18px 12px;
|
||||
background: var(--card);
|
||||
@@ -595,12 +548,4 @@ onMounted(() => {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.page-intro {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user