master
2358328281@qq.com 4 months ago
parent 64af1aa70e
commit 7f2103a36b

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Arrow 1" d="M9.25 2C9.25 1.58579 9.58579 1.25 10 1.25C10.4142 1.25 10.75 1.58579 10.75 2L10 2L9.25 2ZM10.5303 17.5303C10.2374 17.8232 9.76256 17.8232 9.46967 17.5303L4.6967 12.7574C4.40381 12.4645 4.40381 11.9896 4.6967 11.6967C4.98959 11.4038 5.46447 11.4038 5.75736 11.6967L10 15.9393L14.2426 11.6967C14.5355 11.4038 15.0104 11.4038 15.3033 11.6967C15.5962 11.9896 15.5962 12.4645 15.3033 12.7574L10.5303 17.5303ZM10 2L10.75 2L10.75 17L10 17L9.25 17L9.25 2L10 2Z" fill="#FC0505"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 616 B

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Arrow 1" d="M9.25 18C9.25 18.4142 9.58579 18.75 10 18.75C10.4142 18.75 10.75 18.4142 10.75 18L10 18L9.25 18ZM10.5303 2.46967C10.2374 2.17678 9.76256 2.17678 9.46967 2.46967L4.6967 7.24264C4.40381 7.53553 4.40381 8.01041 4.6967 8.3033C4.98959 8.59619 5.46447 8.59619 5.75736 8.3033L10 4.06066L14.2426 8.3033C14.5355 8.59619 15.0104 8.59619 15.3033 8.3033C15.5962 8.01041 15.5962 7.53553 15.3033 7.24264L10.5303 2.46967ZM10 18L10.75 18L10.75 3L10 3L9.25 3L9.25 18L10 18Z" fill="#34C759"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 620 B

@ -0,0 +1,15 @@
<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1500_984)">
<path id="Vector" d="M88 44C88 55.6695 83.3643 66.8611 75.1127 75.1127C66.8611 83.3643 55.6695 88 44 88C32.3305 88 21.1389 83.3643 12.8873 75.1127C4.6357 66.8611 0 55.6695 0 44C0 32.3305 4.6357 21.1389 12.8873 12.8873C21.1389 4.6357 32.3305 0 44 0C55.6695 0 66.8611 4.6357 75.1127 12.8873C83.3643 21.1389 88 32.3305 88 44Z" fill="url(#paint0_linear_1500_984)"/>
<path id="Vector_2" d="M62.7635 24.2976H52.3967V22.4213C52.3967 21.9053 52.1856 21.4362 51.8572 21.0844C51.5054 20.756 51.0363 20.5449 50.5203 20.5449C49.4883 20.5449 48.644 21.3893 48.644 22.4213V24.2976H39.2622V22.4213C39.2622 21.9053 39.0511 21.4362 38.7228 21.0844C38.371 20.756 37.9019 20.5449 37.3859 20.5449C36.3539 20.5449 35.5095 21.3893 35.5095 22.4213V24.2976H25.2365C22.6565 24.2976 20.5457 26.4085 20.5457 28.9885V62.7627C20.5457 65.3427 22.6565 67.4536 25.2365 67.4536H62.7635C65.3435 67.4536 67.4543 65.3427 67.4543 62.7627V28.9885C67.4543 26.3851 65.3435 24.2976 62.7635 24.2976ZM53.757 45.9694L42.1002 57.6966L42.0298 57.767C41.936 57.8608 41.8422 57.9311 41.7484 58.0015C41.6546 58.0719 41.5373 58.1188 41.42 58.1657C41.2558 58.2361 41.0682 58.283 40.904 58.283H40.7164C40.2473 58.283 39.7782 58.0953 39.4029 57.7435L39.356 57.6966L34.1961 52.4897C33.469 51.7627 33.469 50.5899 34.1961 49.8863C34.5714 49.511 35.0404 49.3469 35.4861 49.3469C35.9552 49.3469 36.4242 49.5345 36.7995 49.8863L40.7164 53.8266L51.1536 43.3425C51.8807 42.6155 53.0534 42.6155 53.757 43.3425C54.1792 43.6709 54.3199 44.1634 54.3199 44.6325C54.3199 45.1016 54.1323 45.5942 53.757 45.9694ZM63.7017 33.6794H24.2983V28.9885C24.2983 28.4725 24.7205 28.0503 25.2365 28.0503H35.5095V29.9267C35.5095 30.9587 36.3539 31.803 37.3859 31.803C38.4179 31.803 39.2622 30.9587 39.2622 29.9267V28.0503H48.644V29.9267C48.644 30.9587 49.4883 31.803 50.5203 31.803C51.5523 31.803 52.3967 30.9587 52.3967 29.9267V28.0503H62.7635C63.2795 28.0503 63.7017 28.4725 63.7017 28.9885V33.6794Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_1500_984" x1="44" y1="0" x2="44" y2="88" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFAD28"/>
<stop offset="1" stop-color="#FF8511"/>
</linearGradient>
<clipPath id="clip0_1500_984">
<rect width="88" height="88" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -0,0 +1,19 @@
<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1500_987)">
<path id="Vector" d="M88 44C88 55.6695 83.3643 66.8611 75.1127 75.1127C66.8611 83.3643 55.6695 88 44 88C32.3305 88 21.1389 83.3643 12.8873 75.1127C4.6357 66.8611 0 55.6695 0 44C0 32.3305 4.6357 21.1389 12.8873 12.8873C21.1389 4.6357 32.3305 0 44 0C55.6695 0 66.8611 4.6357 75.1127 12.8873C83.3643 21.1389 88 32.3305 88 44Z" fill="url(#paint0_linear_1500_987)"/>
<g id="Frame_2">
<path id="Vector_2" d="M33.7447 31.4282H52.5492C53.4969 31.4343 54.4106 31.0809 55.0997 30.4416C55.4313 30.1342 55.6955 29.7634 55.8758 29.352C56.0562 28.9406 56.1489 28.4972 56.1483 28.0493V20.9336H48.95C48.7078 19.5496 47.9684 18.2969 46.8661 17.4029C45.7637 16.509 44.3716 16.0331 42.9425 16.0618C41.5786 16.1084 40.2737 16.6202 39.252 17.5092C38.2304 18.3982 37.5557 19.609 37.3438 20.9336H30.1455V28.058C30.1449 28.506 30.2377 28.9493 30.418 29.3607C30.5983 29.7721 30.8625 30.1429 31.1942 30.4503C31.8832 31.0896 32.7969 31.4431 33.7447 31.4369V31.4282Z" fill="white"/>
<path id="Vector_3" d="M59.7209 48.8027C57.5551 48.801 55.4374 49.4304 53.6358 50.6113C51.8341 51.7922 50.4294 53.4715 49.5994 55.4368C48.7694 57.4022 48.5513 59.5652 48.9727 61.6523C49.3942 63.7395 50.4362 65.6569 51.967 67.1621C53.4979 68.6673 55.4487 69.6926 57.5728 70.1084C59.6968 70.5241 61.8987 70.3116 63.8998 69.4977C65.9009 68.6838 67.6113 67.3052 68.8147 65.5361C70.0182 63.767 70.6605 61.687 70.6605 59.5592C70.6605 56.7079 69.5082 53.9733 67.4569 51.9563C65.4056 49.9393 62.6231 48.8051 59.7209 48.8027ZM66.9281 57.6821L60.5296 63.6802C60.3846 63.8554 60.2017 63.9966 59.9941 64.0936C59.7866 64.1906 59.5597 64.2409 59.3299 64.2409C59.1 64.2409 58.8731 64.1906 58.6656 64.0936C58.458 63.9966 58.2751 63.8554 58.1301 63.6802L54.1311 59.9259C53.9459 59.7997 53.7946 59.6314 53.6902 59.4352C53.5857 59.2391 53.5311 59.021 53.5311 58.7996C53.5311 58.5782 53.5857 58.3602 53.6902 58.164C53.7946 57.9679 53.9459 57.7995 54.1311 57.6733C54.2761 57.4982 54.459 57.3569 54.6665 57.2599C54.874 57.1629 55.101 57.1126 55.3308 57.1126C55.5606 57.1126 55.7875 57.1629 55.9951 57.2599C56.2026 57.3569 56.3855 57.4982 56.5305 57.6733L59.3299 60.2926L64.5286 55.4208C64.6736 55.2456 64.8565 55.1044 65.0641 55.0074C65.2716 54.9104 65.4985 54.86 65.7284 54.86C65.9582 54.86 66.1851 54.9104 66.3926 55.0074C66.6002 55.1044 66.7831 55.2456 66.9281 55.4208C67.1145 55.5474 67.2668 55.7167 67.3717 55.9141C67.4766 56.1115 67.5309 56.331 67.5301 56.5537C67.5292 56.7764 67.4731 56.9955 67.3667 57.1921C67.2602 57.3887 67.1066 57.5569 66.9192 57.6821H66.9281Z" fill="white"/>
<path id="Vector_4" d="M45.9996 61.7421C45.8835 61.1424 45.8093 60.5357 45.7774 59.926H29.3457C29.0844 59.9436 28.8223 59.9082 28.5755 59.822C28.3287 59.7358 28.1027 59.6007 27.9113 59.4251C27.7199 59.2495 27.5673 59.0371 27.463 58.8011C27.3586 58.5651 27.3048 58.3106 27.3048 58.0533C27.3048 57.796 27.3586 57.5414 27.463 57.3054C27.5673 57.0695 27.7199 56.8571 27.9113 56.6814C28.1027 56.5058 28.3287 56.3707 28.5755 56.2845C28.8223 56.1984 29.0844 56.163 29.3457 56.1805H46.1418C46.2573 55.8574 46.3817 55.5344 46.515 55.2114C47.152 53.3357 48.1984 51.6193 49.581 50.1824H29.3457C29.0844 50.1999 28.8223 50.1645 28.5755 50.0783C28.3287 49.9921 28.1027 49.857 27.9113 49.6814C27.7199 49.5058 27.5673 49.2934 27.463 49.0574C27.3586 48.8214 27.3048 48.5669 27.3048 48.3096C27.3048 48.0523 27.3586 47.7978 27.463 47.5618C27.5673 47.3258 27.7199 47.1134 27.9113 46.9378C28.1027 46.7621 28.3287 46.627 28.5755 46.5409C28.8223 46.4547 29.0844 46.4193 29.3457 46.4368H55.8728C57.8148 45.8848 59.8567 45.7597 61.8534 46.0706C63.8501 46.3814 65.7526 47.1205 67.4257 48.2354L68.181 48.6807V27.6828C68.1777 26.7844 67.9866 25.8962 67.6198 25.0732C67.2529 24.2502 66.718 23.51 66.0482 22.8983C64.6692 21.622 62.8417 20.9182 60.9472 20.9338H59.7475V28.0583C59.7486 28.9542 59.5632 29.8408 59.2025 30.6636C58.8418 31.4865 58.3135 32.2281 57.6502 32.8428C56.2711 34.1191 54.4437 34.8229 52.5491 34.8073H33.7447C31.8501 34.8229 30.0227 34.1191 28.6436 32.8428C27.9803 32.2281 27.452 31.4865 27.0913 30.6636C26.7306 29.8408 26.5452 28.9542 26.5463 28.0583V20.9338H24.5379C22.6434 20.9182 20.8159 21.622 19.4369 22.8983C18.7735 23.513 18.2453 24.2546 17.8846 25.0775C17.5239 25.9003 17.3384 26.7869 17.3396 27.6828V65.182C17.3396 66.0765 17.5257 66.9614 17.8863 67.7827C18.247 68.6039 18.7746 69.3441 19.4369 69.9579C20.8146 71.2373 22.6422 71.9443 24.5379 71.931H54.0066C51.9194 71.0142 50.0959 69.6039 48.6987 67.826C47.3015 66.048 46.3742 63.9579 45.9996 61.7421Z" fill="white"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_1500_987" x1="44" y1="0" x2="44" y2="88" gradientUnits="userSpaceOnUse">
<stop stop-color="#24DE80"/>
<stop offset="1" stop-color="#0ACB5C"/>
</linearGradient>
<clipPath id="clip0_1500_987">
<rect width="88" height="88" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M15.8333 16.834H4.16667V7.66732H15.8333M15.8333 3.50065H15V2.66732C15 2.20708 14.6269 1.83398 14.1667 1.83398C13.7064 1.83398 13.3333 2.20708 13.3333 2.66732V3.50065H6.66667V2.66732C6.66667 2.20708 6.29357 1.83398 5.83333 1.83398C5.3731 1.83398 5 2.20708 5 2.66732V3.50065H4.16667C3.72464 3.50065 3.30072 3.67625 2.98816 3.98881C2.67559 4.30137 2.5 4.72529 2.5 5.16732V16.834C2.5 17.276 2.67559 17.6999 2.98816 18.0125C3.30072 18.3251 3.72464 18.5007 4.16667 18.5007H15.8333C16.2754 18.5007 16.6993 18.3251 17.0118 18.0125C17.3244 17.6999 17.5 17.276 17.5 16.834V5.16732C17.5 4.72529 17.3244 4.30137 17.0118 3.98881C16.6993 3.67625 16.2754 3.50065 15.8333 3.50065ZM13.3333 10.659C13.5773 10.4151 13.5773 10.0196 13.3333 9.77565C13.0894 9.53173 12.6939 9.53173 12.45 9.77565L8.825 13.4007L7.5 12.0757C7.25607 11.8317 6.86059 11.8317 6.61667 12.0757C6.37274 12.3196 6.37274 12.7151 6.61667 12.959L8.33426 14.6766C8.60529 14.9476 9.04471 14.9476 9.31574 14.6766L13.3333 10.659Z" fill="#FF8D28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -144,6 +144,14 @@ $primaryLightBlue: #3289fb;
padding: 8px 30px !important;
line-height: 18px !important;
border-radius: 8px !important;
cursor: pointer;
&.disabled-done {
opacity: 0.3;
pointer-events: none;
cursor: not-allowed;
}
&.smaller {
padding: 6px 15px !important;
@ -180,4 +188,12 @@ $primaryLightBlue: #3289fb;
--el-button-text-color: #00c0e8 !important;
}
}
&.yellow-sign {
--el-button-border-color: #ff383c !important;
&.is-plain {
--el-button-text-color: #ff383c !important;
}
}
}

@ -0,0 +1,241 @@
<!--
共用"奖励结果"弹窗组件(顶层 el-dialog,不嵌入任何父级,避免被父级 overflow 隐藏)
- 用于抽奖 / 签到 / 提交工单 等场景的成功反馈
- 通过 useRewardResultStore 控制显隐
- Props:
title - 标题文案,如 "恭喜抽中" / "签到成功" / "提交工单"
icon - 大图标 url
points - 本次获得的积分(0 / null 时不显示积分区块)
batchGoal - 兑换目标积分(默认 500)
batchCurrent - 当前积分(默认 0)
rewardName - 兑换物名称(默认 "康小虎 AI 吉祥物")
- Events:
claim - "开心收下"
viewAccount - "查看我的资产"
-->
<template>
<el-dialog :model-value="visible" @update:model-value="onDialogUpdate" :show-close="false"
:close-on-click-modal="true" width="auto" align-center append-to-body class="reward-result-dialog"
@closed="onClosed">
<div class="reward-result">
<div class="main-wrap">
<div class="result-icon-wrap">
<img :src="icon" class="result-icon" alt="" />
</div>
<div class="result-title">{{ title }}</div>
<div v-if="showPoints" class="result-points">
<span class="plus">+</span>
<span class="value">{{ points }}&nbsp;&nbsp;</span>
<span class="unit">积分</span>
</div>
<div class="batch-wrap">
<div class="batch-info">
<span class="batch-label">距离兑换 {{ rewardName }}</span>
<span class="batch-remaining">还差{{ batchRemaining }}分</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: batchPercent + '%' }" />
</div>
</div>
</div>
<div class="result-actions">
<el-button type="primary" plain class="btn-secondary" @click="onClaim">开心收下</el-button>
<el-button type="primary" class="btn-primary" @click="onViewAccount">查看我的资产</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { computed } from "vue";
import { useRewardResultStore } from "@/stores/rewardResult";
const store = useRewardResultStore();
const visible = computed({
get: () => store.visible,
set: (v) => {
if (!v) store.close();
},
});
const title = computed(() => store.title);
const icon = computed(() => store.icon);
const points = computed(() => store.points);
const batchGoal = computed(() => store.batchGoal);
const batchCurrent = computed(() => store.batchCurrent);
const rewardName = computed(() => store.rewardName);
const showPoints = computed(() => Number(points.value) > 0);
const batchRemaining = computed(() =>
Math.max(0, Number(batchGoal.value) - Number(batchCurrent.value)),
);
const batchPercent = computed(() => {
const goal = Number(batchGoal.value) || 0;
if (goal <= 0) return 0;
return Math.min(100, Math.round((Number(batchCurrent.value) / goal) * 100));
});
const onDialogUpdate = (val) => {
if (!val) store.close();
};
const onClosed = () => {
// 关闭动画结束后清理一次数据,防止下次 open 时闪现旧内容
store.clear();
};
const onClaim = () => store.close();
const onViewAccount = () => {
store.close();
// 通知外部:查看我的资产
store.requestViewAccount();
};
</script>
<style lang="scss">
// ===== 顶部 keyframes =====
@keyframes resultPop {
0% {
transform: scale(0.5);
opacity: 0;
}
60% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
// ===== 弹窗外壳 =====
.reward-result-dialog {
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
// ===== 结果视图(与原 LotteryDialog 的 .lottery-result 完全同款)=====
.reward-result {
width: 458px;
background: #FFFFFF;
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.06);
border-radius: 24px;
overflow: hidden;
.main-wrap {
padding: 30px 46px 20px;
display: flex;
flex-direction: column;
align-items: center;
.result-icon-wrap {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: resultPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.result-icon {
width: 110px;
height: 110px;
}
.result-title {
font-weight: 600;
font-size: 24px;
color: #1D2129;
margin: 16px auto;
}
.result-points {
font-weight: 600;
font-size: 24px;
color: #FF8D28;
.plus {
font-size: 15px;
}
.unit {
font-size: 16px;
font-weight: 500;
}
}
.batch-wrap {
margin-top: 16px;
background: #F9FAFF;
border-radius: 10px 10px 10px 10px;
width: 100%;
padding: 18px 15px;
.batch-info {
display: flex;
justify-content: space-between;
font-size: 14px;
.batch-label {
color: #666666;
}
.batch-remaining {
color: #FF8D28;
}
}
.progress-bar {
margin-top: 6px;
height: 6px;
background: #E2E2E2;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #FF7800 0%, #FF8D28 100%);
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
}
}
}
.result-actions {
background: #F9FAFF;
display: flex;
border-top: 1px solid #E0E0E0;
align-items: center;
justify-content: flex-end;
padding: 16px 24px;
--el-color-primary: #6155F5;
--el-button-border-color: #6155F5;
--el-button-hover-bg-color: #6155F5;
--el-border-color: #6155F5;
button {
width: 126px;
height: 38px;
border-radius: 8px;
font-size: 14px;
font-weight: normal;
transition: all 0.2s;
border-color: #6155F5;
&:hover {
background: #6155F5;
}
}
}
}
</style>

@ -59,7 +59,7 @@
</div>
</header>
<main class="admin-content">
<main class="admin-content">
<slot />
</main>
@ -90,11 +90,29 @@
<!-- 修改密码弹窗(管理端 / 医院端共用,根据 role 走对应接口) -->
<PasswordDialog v-model="passwordDialogVisible" :role="passwordRole" />
<!-- 全局签到弹窗(医院端登录后自动触发,未签到时弹出) -->
<SignInDialog v-model="signInDialogStore.visible"
:signed-in="signInDialogStore.summary.isSignIn"
:total-points="signInDialogStore.summary.totalPoints"
:consecutive-sign-in-days="signInDialogStore.summary.consecutiveSignInDays"
:sign-in-rank="signInDialogStore.summary.signInRank"
:beat-percent="signInDialogStore.summary.beatPercent"
@success="onSignInDialogSuccess" />
<!-- 全局奖励结果弹窗(抽奖/签到/提交工单 共用) -->
<RewardResultDialog />
<!-- 全局查看我的资产弹窗(由 RewardResultDialog 中点击"查看我的资产"触发) -->
<AssetsDialog v-model="assetDialogStore.visible" :total-points="assetDialogStore.totalPoints"
:exchange-goal="assetDialogStore.exchangeGoal" :reward-name="assetDialogStore.rewardName"
:today-order="assetDialogStore.todayOrder" :today-sign-in="assetDialogStore.todaySignIn"
:today-draw="assetDialogStore.todayDraw" />
</div>
</template>
<script setup>
import { computed, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
@ -102,8 +120,16 @@ import SvgIcon from "@/components/SvgIcon/index.vue";
import { useUserStore } from "@/stores/api/user";
import { useThemeStore } from "@/stores/theme";
import { useOrderDialogStore } from "@/stores/orderDialog";
import { useSignInDialogStore } from "@/stores/signInDialog";
import { useRewardResultStore } from "@/stores/rewardResult";
import { useAssetDialogStore } from "@/stores/assetDialog";
import OrderFormDialog from "@/views/hospital/orders/OrderFormDialog.vue";
import PasswordDialog from "@/components/PasswordDialog.vue";
import SignInDialog from "@/views/hospital/SignInDialog.vue";
import RewardResultDialog from "@/components/RewardResultDialog.vue";
import AssetsDialog from "@/views/hospital/AssetsDialog.vue";
import { getPoints, getPointsDetails } from "@/service/modular/hospital";
import dayjs from "dayjs";
// 顶部菜单图标(直接以静态资源方式 import,绕开 SvgIcon 的 fill 干扰)
// 管理端
@ -136,6 +162,9 @@ const router = useRouter();
const userStore = useUserStore();
const themeStore = useThemeStore();
const orderDialogStore = useOrderDialogStore();
const signInDialogStore = useSignInDialogStore();
const rewardResultStore = useRewardResultStore();
const assetDialogStore = useAssetDialogStore();
// 当前是否为医院端(用于切换头部布局样式)
const isHospitalMode = computed(() => route.path.startsWith("/hospital"));
@ -146,6 +175,14 @@ const onOrderDialogSuccess = () => {
window.dispatchEvent(new CustomEvent("hospital-order-saved"));
};
// 全局签到弹窗:签到成功后(已由 SignInDialog 自己 fireSigned + open reward result)
// 这里只负责同步刷新 summary 给后续 /hospital/points 页面用
const onSignInDialogSuccess = () => {
signInDialogStore.refreshSummary();
// 通知积分页同步刷新(向后兼容)
window.dispatchEvent(new CustomEvent("hospital-signin-success"));
};
// 主题弹窗显隐
const themeDialogVisible = ref(false);
@ -154,6 +191,69 @@ const passwordDialogVisible = ref(false);
// 修改密码:根据当前所在端选择对应接口
const passwordRole = computed(() => (isHospitalMode.value ? "hospital" : "admin"));
// 注册全局回调(只在第一次挂载时注册)
onMounted(() => {
// 1) 签到成功(外层) -> 关闭签到弹窗 + 打开全局奖励结果弹窗
// (SignInDialog 内部已经自己关闭 + 打开 result; 这里只刷新 summary)
if (typeof signInDialogStore.onSigned !== "function" || !signInDialogStore.onSigned.__layoutHook) {
const hook = async () => {
await signInDialogStore.refreshSummary();
};
hook.__layoutHook = true;
signInDialogStore.onSigned = hook;
}
// 2) 奖励结果弹窗"查看我的资产" -> 取最新数据后打开 AssetsDialog
if (typeof rewardResultStore.onViewAccount !== "function" || !rewardResultStore.onViewAccount.__layoutHook) {
const hook = async () => {
await openAssetsDialogWithFreshData();
};
hook.__layoutHook = true;
rewardResultStore.onViewAccount = hook;
}
// 3) 医院端:第一次进入 layout 时检查一次今日签到状态
if (!userStore.token) return;
if (userStore.userInfo?.userType !== "Hospital") return;
signInDialogStore.checkAndShow();
});
// 打开 AssetsDialog 时先拉最新积分和今日明细
const openAssetsDialogWithFreshData = async () => {
try {
const [summaryRes, detailsRes] = await Promise.all([
getPoints(),
getPointsDetails({ page: 1, pageSize: 50 }),
]);
const summary = summaryRes?.data ?? summaryRes ?? {};
const detailsData = detailsRes?.data ?? detailsRes ?? {};
const list = detailsData.list || [];
const today = dayjs().format("YYYY-MM-DD");
const sumBy = (test) =>
list
.filter(
(r) =>
r.createdAt &&
String(r.createdAt).startsWith(today) &&
test(r.source),
)
.reduce(
(s, r) => s + Math.abs(Number(r.changeAmount) || 0),
0,
);
assetDialogStore.open({
totalPoints: Number(summary.totalPoints) || 0,
todayOrder: sumBy((s) => /工单|order/i.test(s || "")),
todaySignIn: sumBy((s) => /签到|签/i.test(s || "")),
todayDraw: sumBy((s) => /抽奖|抽/i.test(s || "")),
});
} catch (_) {
assetDialogStore.open();
}
};
// 预设主题配置(与现有 scss 主题保持一致)
const presetThemes = [
{
@ -300,13 +400,6 @@ const handleCommand = async (cmd) => {
border: 1px solid rgba(0, 0, 0, 0.1);
background: linear-gradient(to right, #f9faff 0, #f9faff 75%, #a6cffe 100%);
// .header-left {
// display: flex;
// align-items: center;
// height: 100%;
// flex: 1;
// min-width: 0;
.logo-bar {
display: flex;
align-items: center;
@ -317,7 +410,6 @@ const handleCommand = async (cmd) => {
white-space: nowrap;
height: 100%;
// 管理端:渐变文字
.logo-text {
background: linear-gradient(to right, #3fc5fd, #3b90fa);
-webkit-background-clip: text;
@ -337,7 +429,6 @@ const handleCommand = async (cmd) => {
min-width: 0;
}
// 医院端:菜单旁的快速提交按钮容器
.header-quick-submit {
margin-left: 14px;
}
@ -366,8 +457,6 @@ const handleCommand = async (cmd) => {
}
}
// }
.header-right {
display: flex;
align-items: center;
@ -430,7 +519,7 @@ const handleCommand = async (cmd) => {
}
}
.admin-content {
.admin-content {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
@ -557,4 +646,4 @@ const handleCommand = async (cmd) => {
}
}
}
</style>
</style>

@ -0,0 +1,44 @@
import { defineStore } from "pinia";
import { ref } from "vue";
/**
* 全局"查看我的资产"弹窗状态(AdminLayout 承载 AssetsDialog)
* - 三个场景(抽奖/签到/提交工单)的 RewardResultDialog 中点击"查看我的资产"时调用 open
*/
export const useAssetDialogStore = defineStore("assetDialog", () => {
const visible = ref(false);
// 透传给 AssetsDialog 的 props
const totalPoints = ref(0);
const exchangeGoal = ref(500);
const rewardName = ref("康小虎 AI 吉祥物");
const todayOrder = ref(0);
const todaySignIn = ref(0);
const todayDraw = ref(0);
const open = (opts = {}) => {
totalPoints.value = Number(opts.totalPoints) || 0;
exchangeGoal.value = Number(opts.exchangeGoal) || 500;
rewardName.value = opts.rewardName || "康小虎 AI 吉祥物";
todayOrder.value = Number(opts.todayOrder) || 0;
todaySignIn.value = Number(opts.todaySignIn) || 0;
todayDraw.value = Number(opts.todayDraw) || 0;
visible.value = true;
};
const close = () => {
visible.value = false;
};
return {
visible,
totalPoints,
exchangeGoal,
rewardName,
todayOrder,
todaySignIn,
todayDraw,
open,
close,
};
});

@ -0,0 +1,66 @@
import { defineStore } from "pinia";
import { ref } from "vue";
/**
* 全局"奖励结果"弹窗状态(AdminLayout 承载组件)
* - 抽奖 / 签到 / 提交工单 三个场景成功后统一通过此 store 打开
* - 用户点击"查看我的资产"时,触发 requestViewAccount 事件,由父级打开 AssetsDialog
*/
export const useRewardResultStore = defineStore("rewardResult", () => {
const visible = ref(false);
const title = ref("恭喜抽中");
const icon = ref("");
const points = ref(0);
const batchGoal = ref(500);
const batchCurrent = ref(0);
const rewardName = ref("康小虎 AI 吉祥物");
// 触发"查看我的资产"事件,AdminLayout 监听后打开 AssetsDialog
const onViewAccount = ref(null);
const open = (opts = {}) => {
title.value = opts.title || "恭喜抽中";
icon.value = opts.icon || "";
points.value = Number(opts.points) || 0;
batchGoal.value = Number(opts.batchGoal) || 500;
batchCurrent.value = Number(opts.batchCurrent) || 0;
rewardName.value = opts.rewardName || "康小虎 AI 吉祥物";
visible.value = true;
};
const close = () => {
visible.value = false;
};
// 关闭动画结束后清理数据
const clear = () => {
title.value = "恭喜抽中";
icon.value = "";
points.value = 0;
batchGoal.value = 500;
batchCurrent.value = 0;
rewardName.value = "康小虎 AI 吉祥物";
};
const requestViewAccount = () => {
if (typeof onViewAccount.value === "function") {
onViewAccount.value();
}
};
return {
visible,
title,
icon,
points,
batchGoal,
batchCurrent,
rewardName,
onViewAccount,
open,
close,
clear,
requestViewAccount,
};
});

@ -0,0 +1,129 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import dayjs from "dayjs";
import { getPoints } from "@/service/modular/hospital";
// sessionStorage key:当天是否已经处理过(弹过 / 已签到不再弹)
const POPUP_FLAG_KEY = "csm_signin_popup_date";
/**
* 全局签到弹窗状态(AdminLayout 承载组件,登录后由 layout 触发)
* - open() 打开
* - close() 关闭 + 标记今日已处理
* - checkAndShow() 登录后调用:取 summary 判断是否需要弹
* - refreshSummary() 签到成功后刷新 summary 数据
* - fireSigned() 签到成功后由 SignInDialog 调用,触发 onSigned 回调
*/
export const useSignInDialogStore = defineStore("signInDialog", () => {
const visible = ref(false);
const loading = ref(false);
// 透传给 SignInDialog 的字段(来自 /hospital/points 接口 summary)
const summary = ref({
isSignIn: false,
totalPoints: 0,
consecutiveSignInDays: 0,
signInRank: 0,
beatPercent: 0,
});
// 签到成功回调(AdminLayout 注册,用于打开 RewardResultDialog)
const onSigned = ref(null);
const todayKey = () => dayjs().format("YYYY-MM-DD");
// 当天是否已经处理过(弹过 / 已签到跳过)
const hasHandledToday = () => {
try {
return sessionStorage.getItem(POPUP_FLAG_KEY) === todayKey();
} catch {
return false;
}
};
const markHandledToday = () => {
try {
sessionStorage.setItem(POPUP_FLAG_KEY, todayKey());
} catch {
/* sessionStorage 不可用时静默忽略 */
}
};
const unwrap = (res) => res?.data ?? res ?? {};
const setSummary = (data) => {
summary.value = {
isSignIn: !!data?.isSignIn,
totalPoints: Number(data?.totalPoints) || 0,
consecutiveSignInDays: Number(data?.consecutiveSignInDays) || 0,
signInRank: Number(data?.signInRank) || 0,
beatPercent: Number(data?.beatPercent) || 0,
};
};
const open = () => {
visible.value = true;
};
const close = () => {
visible.value = false;
// 用户主动关闭:标记今天不再弹(避免反复打扰)
markHandledToday();
};
/**
* 登录后 / 路由进入 layout 时调用:
* 1) sessionStorage 已标记今天处理过 → 不再请求接口、不弹
* 2) 调 getPoints 取 summary,更新 store
* 3) 今日未签到才弹(已签到不弹,但仍标记今天已处理)
*/
const checkAndShow = async () => {
if (hasHandledToday()) return;
if (loading.value) return;
loading.value = true;
try {
const res = unwrap(await getPoints());
setSummary(res);
if (!summary.value.isSignIn) {
open();
}
// 不论是否弹,都标记今天已处理
markHandledToday();
} catch (_) {
// 接口失败时不弹也不标记,下次重试
} finally {
loading.value = false;
}
};
// 签到成功后刷新 summary(不弹窗,仅更新数据)
const refreshSummary = async () => {
try {
const res = unwrap(await getPoints());
setSummary(res);
return res;
} catch (_) {
/* 静默失败,不影响签到成功提示 */
return null;
}
};
// 触发签到成功回调
const fireSigned = () => {
if (typeof onSigned.value === "function") {
onSigned.value();
}
};
return {
visible,
loading,
summary,
onSigned,
open,
close,
checkAndShow,
refreshSummary,
fireSigned,
};
});

@ -1,13 +1,6 @@
<template>
<el-dialog
:model-value="visible"
class="account-edit-dialog"
top="6vh"
width="640px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="(v) => !v && close()"
>
<el-dialog :model-value="visible" class="account-edit-dialog" top="6vh" width="640px" destroy-on-close
:close-on-click-modal="false" @update:model-value="(v) => !v && close()">
<div class="body-wrap">
<div class="body-header">
<span class="body-title">
@ -17,13 +10,7 @@
</div>
<div class="account-edit" v-loading="loading">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
label-position="right"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="right">
<!-- 医院账号字段:编辑时 医院名称/账号(只读)/姓名/性别;新增时 医院名称/账号/密码/姓名/性别 -->
<template v-if="props.userType === 'Hospital'">
<!-- 新增模式 -->
@ -31,19 +18,9 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.hospital')" prop="hospitalId">
<el-select
v-model="form.hospitalId"
:placeholder="$t('msg.pleaseSelectHospital')"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="item in hospitalOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
<el-select v-model="form.hospitalId" :placeholder="$t('msg.pleaseSelectHospital')" filterable
clearable style="width: 100%">
<el-option v-for="item in hospitalOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
@ -56,12 +33,8 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('label.password')" prop="password">
<el-input
v-model="form.password"
:type="showPwd ? 'text' : 'password'"
autocomplete="new-password"
:placeholder="$t('placeholder.password')"
>
<el-input v-model="form.password" :type="showPwd ? 'text' : 'password'" autocomplete="new-password"
:placeholder="$t('placeholder.password')">
<template #suffix>
<el-icon class="pwd-toggle-icon" @click="showPwd = !showPwd">
<View v-if="showPwd" />
@ -93,20 +66,9 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.hospital')" prop="hospitalId">
<el-select
:key="`hospital-${hospitalOptions.length}`"
v-model="form.hospitalId"
:placeholder="$t('msg.pleaseSelectHospital')"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="item in hospitalOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
<el-select :key="`hospital-${hospitalOptions.length}`" v-model="form.hospitalId"
:placeholder="$t('msg.pleaseSelectHospital')" filterable clearable style="width: 100%">
<el-option v-for="item in hospitalOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
@ -146,12 +108,8 @@
</el-col>
<el-col :span="12">
<el-form-item :label="$t('label.password')" prop="password">
<el-input
v-model="form.password"
:type="showPwd ? 'text' : 'password'"
autocomplete="new-password"
:placeholder="$t('placeholder.password')"
>
<el-input v-model="form.password" :type="showPwd ? 'text' : 'password'" autocomplete="new-password"
:placeholder="$t('placeholder.password')">
<template #suffix>
<el-icon class="pwd-toggle-icon" @click="showPwd = !showPwd">
<View v-if="showPwd" />
@ -394,7 +352,12 @@ watch(
if (vis) {
showPwd.value = false;
resetForm();
if (id) fillFormFromRow();
if (id) { fillFormFromRow() }
else {
if (props.userType === 'Hospital') {
loadHospitals();
}
}
}
},
{ immediate: true }
@ -434,10 +397,12 @@ watch(
width: 30px !important;
min-width: 30px !important;
}
.el-input.is-show-password .el-input__inner {
padding-right: 50px !important;
box-sizing: border-box;
}
// 去掉密码框自身的过渡动画,避免后缀区出现/隐藏时插值产生的轻微位移
.el-input.is-show-password,
.el-input.is-show-password .el-input__inner,

@ -245,7 +245,10 @@ const viewReport = async (attachmentPath) => {
}
};
onMounted(() => { loadList(), hospitalStore.ensureLoaded(); });
onMounted(() => {
hospitalStore.ensureLoaded();
loadList();
});
</script>
<style scoped lang="scss">

@ -538,34 +538,6 @@ onBeforeUnmount(() => {
<style lang="scss">
.admin-order-form-dialog {
.body-wrap {
.body-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 27px;
.body-title {
font-size: 18px;
font-weight: 600;
}
.close-btn {
width: 24px;
height: 24px;
cursor: pointer;
}
}
}
.body-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.rich-editor {
width: 100%;
background: #fff;

@ -203,14 +203,6 @@ const filters = reactive({
keyword: "",
});
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const statusClass = (s) =>
s === "已完成" ? "done" : s === "待处理" ? "pending" : 'info';
@ -351,7 +343,7 @@ const onResize = () => {
viewportWidth.value = window.innerWidth - 120;
};
onMounted(() => {
loadHospitals();
hospitalStore.ensureLoaded();
loadList();
window.addEventListener("resize", onResize);
});

@ -376,7 +376,7 @@ onMounted(() => {
border-radius: 8px 8px 8px 8px !important;
border: 1px solid #E0E0E0 !important;
overflow: hidden;
height: 42px !important;
height: 44px !important;
}
}
}

@ -1,67 +1,37 @@
<template>
<el-dialog :model-value="modelValue" @update:model-value="onDialogUpdate" :show-close="false"
:close-on-click-modal="true" width="420px" align-center append-to-body class="lottery-dialog" @closed="onClosed">
<Transition name="lottery-fade" mode="out-in">
<!-- ============ 抽奖网格 ============ -->
<div v-if="!showResult" key="grid" class="lottery-main">
<div class="lottery-header">
<h2 class="lottery-title">积分抽奖</h2>
<p class="lottery-subtitle">顺时针转动高亮 · 停在哪格就领对应奖励</p>
</div>
<div class="lottery-grid">
<template v-for="(item, idx) in gridItems" :key="idx">
<!-- 中心抽奖按钮 -->
<div v-if="item.type === 'draw'" class="lottery-cell lottery-cell--draw"
:class="{ 'is-disabled': isDrawing || !drawable, 'is-drawed': !drawable }" @click="startDraw">
</div>
<!-- 普通积分格 -->
<div v-else class="lottery-cell" :class="{
'is-active': activeIndex === idx && isDrawing,
'is-winner': winnerIndex === idx && !isDrawing,
}">
<span class="points-value">{{ item.points }}积分</span>
</div>
</template>
</div>
<div class="lottery-main">
<div class="lottery-header">
<h2 class="lottery-title">积分抽奖</h2>
<p class="lottery-subtitle">顺时针转动高亮 · 停在哪格就领对应奖励</p>
</div>
<!-- ============ 结果弹窗 ============ -->
<div v-else key="result" class="lottery-result">
<div class="main-wrap">
<div class="result-icon-wrap">
<img :src="resultIcon" class="result-icon" alt="" />
<div class="lottery-grid">
<template v-for="(item, idx) in gridItems" :key="idx">
<!-- 中心抽奖按钮 -->
<div v-if="item.type === 'draw'" class="lottery-cell lottery-cell--draw"
:class="{ 'is-disabled': isDrawing || !drawable, 'is-drawed': !drawable }" @click="startDraw">
</div>
<div class="result-title">恭喜抽中</div>
<div class="result-points">
<span class="plus">+</span>
<span class="value">{{ winningPoints }}&nbsp;&nbsp;</span>
<span class="unit">积分</span>
<!-- 普通积分格 -->
<div v-else class="lottery-cell" :class="{
'is-active': activeIndex === idx && isDrawing,
'is-winner': winnerIndex === idx && !isDrawing,
}">
<span class="points-value">{{ item.points }}积分</span>
</div>
<div class="batch-wrap">
<div class="batch-info">
<span class="batch-label">距离兑换 康小虎 AI 吉祥物</span>
<span class="batch-remaining">还差{{ batchRemaining }}分</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: batchPercent + '%' }" />
</div>
</div>
</div>
<div class="result-actions">
<el-button type="primary" plain class="btn-secondary" @click="handleClaim">开心收下</el-button>
<el-button type="primary" class="btn-primary" @click="handleViewAccount">查看我的资产</el-button>
</div>
</template>
</div>
</Transition>
</div>
</el-dialog>
</template>
<script setup>
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { draw } from "@/service/modular/hospital";
import resultIcon from "@/assets/image/hospital/ponits/icon-lottery-large.svg";
import { draw, getPoints } from "@/service/modular/hospital";
import { useRewardResultStore } from "@/stores/rewardResult";
import iconLottery from "@/assets/image/hospital/ponits/icon-lottery-large.svg";
const props = defineProps({
modelValue: { type: Boolean, default: false },
@ -81,6 +51,8 @@ const emit = defineEmits([
const unwrap = (res) => res?.data ?? res ?? {};
const rewardResultStore = useRewardResultStore();
// ============ 网格布局(中心是抽奖按钮) ============
const gridItems = [
{ type: "points", points: 1 },
@ -101,14 +73,6 @@ const isDrawing = ref(false);
const activeIndex = ref(-1);
const winnerIndex = ref(-1);
const winningPoints = ref(0);
const showResult = ref(false);
const batchRemaining = computed(() =>
Math.max(0, props.batchGoal - props.batchCurrent),
);
const batchPercent = computed(() =>
Math.min(100, Math.round((props.batchCurrent / props.batchGoal) * 100)),
);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
@ -119,10 +83,20 @@ const findIndexByPoints = (points) => {
);
};
// ============ 取最新积分(用于结果区进度条) ============
const fetchLatestPoints = async () => {
try {
const res = unwrap(await getPoints());
return Number(res?.totalPoints) || 0;
} catch (_) {
return 0;
}
};
// ============ 开始抽奖 ============
const startDraw = async () => {
if (isDrawing.value) return;
if (!props.drawable) {
ElMessage.info("今日已抽奖,明天再来吧!");
return;
@ -132,7 +106,6 @@ const startDraw = async () => {
isDrawing.value = true;
winnerIndex.value = -1;
winningPoints.value = 0;
showResult.value = false;
activeIndex.value = -1;
// 启动接口请求
@ -180,12 +153,10 @@ const startDraw = async () => {
return;
}
// 解析中奖分数(兼容多种返回结构)
// 解析中奖分数(兼容多种返回结构)
const points = Number(
apiResult.points ?? apiResult.score ?? apiResult ?? 0,
) ;
);
// 找积分对应的格子
const targetIdx = findIndexByPoints(points);
@ -204,11 +175,27 @@ const startDraw = async () => {
winnerIndex.value = targetIdx;
winningPoints.value = points;
// 短暂停顿后显示结果弹窗
// 短暂停顿后:关闭抽奖弹窗 -> 打开全局"奖励结果"弹窗
await wait(500);
showResult.value = true;
isDrawing.value = false;
console.log('线上');
// 取最新积分用于结果区进度条
const totalPoints = await fetchLatestPoints();
// 关闭抽奖弹窗,再开全局结果弹窗(放在外面不会被 overflow 隐藏)
emit("update:modelValue", false);
// 下一帧再 open 避免和抽奖弹窗关闭动画叠加
setTimeout(() => {
rewardResultStore.open({
title: "恭喜抽中",
icon: iconLottery,
points,
batchGoal: 500,
batchCurrent: totalPoints,
rewardName: "康小虎 AI 吉祥物",
});
}, 100);
emit("success", { points });
};
@ -227,16 +214,6 @@ const onClosed = () => {
activeIndex.value = -1;
winnerIndex.value = -1;
winningPoints.value = 0;
showResult.value = false;
};
const handleClaim = () => {
emit("update:modelValue", false);
};
const handleViewAccount = () => {
emit("viewAccount");
emit("update:modelValue", false);
};
</script>
@ -256,23 +233,6 @@ const handleViewAccount = () => {
}
}
@keyframes resultPop {
0% {
transform: scale(0.5);
opacity: 0;
}
60% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* ============ 所有样式都嵌套在 .lottery-dialog 下 ============ */
.lottery-dialog {
// ===== 弹窗外壳 =====
@ -297,22 +257,6 @@ const handleViewAccount = () => {
align-items: center;
}
// ===== 转场动画 =====
.lottery-fade-enter-active,
.lottery-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.lottery-fade-enter-from {
opacity: 0;
transform: scale(0.96);
}
.lottery-fade-leave-to {
opacity: 0;
transform: scale(1.04);
}
// ===== 抽奖网格视图 =====
.lottery-main {
padding: 86px 82px 54px 74px;
@ -395,151 +339,5 @@ const handleViewAccount = () => {
}
}
}
// ===== 结果弹窗 =====
.lottery-result {
width: 458px;
// height: 412px;
background: #FFFFFF;
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.06);
border-radius: 24px;
overflow: hidden;
.main-wrap {
padding: 30px 46px 20px;
display: flex;
flex-direction: column;
align-items: center;
.result-icon-wrap {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: resultPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.result-icon {
width: 110px;
height: 110px;
}
.result-title {
font-weight: 600;
font-size: 24px;
color: #1D2129;
margin: 16px auto;
}
.result-points {
font-weight: 600;
font-size: 24px;
color: #FF8D28;
.plus {
font-size: 15px;
}
.unit {
font-size: 16px;
font-weight: 500;
}
}
.batch-wrap {
margin-top: 16px;
background: #F9FAFF;
border-radius: 10px 10px 10px 10px;
width: 100%;
padding: 18px 15px;
.batch-info {
display: flex;
justify-content: space-between;
font-size: 14px;
.batch-label {
color: #666666;
}
.batch-remaining {
color: #FF8D28;
}
}
.progress-bar {
margin-top: 6px;
height: 6px;
background: #E2E2E2;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #FF7800 0%, #FF8D28 100%);
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
}
}
}
.result-actions {
background: #F9FAFF;
display: flex;
border-top: 1px solid #E0E0E0;
display: flex;
align-items: center;
// gap: 12px;
justify-content: flex-end;
padding: 16px 24px;
--el-color-primary: #6155F5;
// --el-button-text-color: #6155F5;
// --el-button-bg-color: #6155F5;
--el-button-border-color: #6155F5;
// --el-button-hover-text-color: #6155F5;
--el-button-hover-bg-color: #6155F5;
--el-border-color: #6155F5;
// --el-button-hover-border-color: #6155F5;
// --el-button-active-text-color: #FFFFFF;
button {
// flex: 1;
width: 126px;
height: 38px;
border-radius: 8px;
font-size: 14px;
font-weight: normal;
transition: all 0.2s;
border-color: #6155F5;
&:hover {
background: #6155F5;
}
}
// .btn-secondary {
// background: #FFFFFF;
// color: #6E41FD;
// border-color: #6E41FD;
// &:hover {
// background: #F3EDFE;
// }
// }
// .btn-primary {
// background: linear-gradient(180deg, #AE6EFE 0%, #6E41FD 100%);
// color: #FFFFFF;
// border-color: transparent;
// &:hover {
// opacity: 0.92;
// transform: translateY(-1px);
// box-shadow: 0 4px 12px rgba(110, 65, 253, 0.35);
// }
// }
}
}
}
</style>
</style>

@ -6,7 +6,7 @@
<div class="signin-header">
<h3 class="signin-title" :class="{ 'is-signed': signedIn }">
<template v-if="signedIn">
连续签到<span class="title-num">{{ streakDays }}</span>天
连续签到<span class="title-num">{{ streakDays }}</span>天
<template v-if="rank > 0">排名第<span class="title-num">{{ rank }}名</span></template>
<template v-else>暂无排名</template>
</template>
@ -70,8 +70,11 @@
import { computed, ref, watch } from "vue";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import { signIn } from "@/service/modular/hospital";
import { signIn, getPoints } from "@/service/modular/hospital";
import { useSignInDialogStore } from "@/stores/signInDialog";
import { useRewardResultStore } from "@/stores/rewardResult";
import checkIcon from "@/assets/image/hospital/ponits/coin.svg";
import iconSign from "@/assets/image/hospital/ponits/icon-sign-large.svg";
const props = defineProps({
modelValue: { type: Boolean, default: false },
@ -141,6 +144,8 @@ watch(
);
const submitting = ref(false);
const signInDialogStore = useSignInDialogStore();
const rewardResultStore = useRewardResultStore();
const onDialogUpdate = (val) => {
if (submitting.value) return;
@ -151,6 +156,16 @@ const onClosed = () => {
submitting.value = false;
};
// 取最新积分(用于结果区进度条)
const fetchLatestPoints = async () => {
try {
const res = unwrap(await getPoints());
return Number(res?.totalPoints) || 0;
} catch (_) {
return 0;
}
};
// ============ 签到 ============
const handleSignIn = async () => {
if (props.signedIn) {
@ -163,8 +178,28 @@ const handleSignIn = async () => {
ElMessage.success("签到成功! +1 积分 🎉");
// 重新生成 7 天(刷新签到状态)
days.value = buildSignInDays(props.consecutiveSignInDays, props.signedIn);
emit("success");
// 取最新积分 + 关闭签到弹窗 + 打开全局奖励结果弹窗
const totalPoints = await fetchLatestPoints();
// 先关闭签到弹窗(在父层),再下一帧 open 全局结果
emit("update:modelValue", false);
setTimeout(() => {
rewardResultStore.open({
title: "签到成功",
icon: iconSign,
points: 1,
batchGoal: 500,
batchCurrent: totalPoints,
rewardName: "康小虎 AI 吉祥物",
});
}, 100);
// 通知外层 signInDialogStore 触发 onSigned 回调(同步刷新 summary)
signInDialogStore.fireSigned();
emit("success");
} catch (e) {
ElMessage.error(e?.message || "签到失败");
} finally {
@ -344,8 +379,6 @@ const handleSignIn = async () => {
}
}
.day-points-text {
color: #BCBCBC;
}
@ -368,7 +401,7 @@ const handleSignIn = async () => {
line-height: 42px;
text-align: center;
margin: 0 auto;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(255, 138, 40, 0.4);

@ -0,0 +1,391 @@
<template>
<el-dialog :model-value="modelValue" class="hospital-order-detail-dialog" width="900px" :close-on-click-modal="true"
top="5vh" @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen" @closed="cleanupPreview">
<div class="body-wrap" v-loading="loading">
<div class="body-header">
<div class="body-title">{{ $t('modal.detail') }}</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<div v-if="detail.id" class="detail-container">
<!-- 第一行:4个 -->
<div class="detail-row row-4">
<div class="detail-item">
<div class="label">工单号</div>
<div class="value">{{ detail.orderNo }}</div>
</div>
<div class="detail-item">
<div class="label">当前状态</div>
<div class="value">{{ detail.status }}</div>
</div>
<div class="detail-item">
<div class="label">优先级</div>
<div class="value">{{ detail.priority }}</div>
</div>
<div class="detail-item">
<div class="label">服务类型</div>
<div class="value">{{ detail.serviceType }}</div>
</div>
</div>
<!-- 第二行:1个 -->
<div class="detail-row row-1">
<div class="detail-item">
<div class="label">问题名称</div>
<div class="value">{{ detail.title }}</div>
</div>
</div>
<!-- 第三行:1个 -->
<div class="detail-row row-1">
<div class="detail-item">
<div class="label">问题描述</div>
<div class="value" style="max-height: 300px; overflow: auto;">
<div class="html-content" v-html="detail.description || '—'"></div>
</div>
</div>
</div>
<!-- 第四行:1个 -->
<div class="detail-row row-1">
<div class="detail-item">
<div class="label">附件</div>
<div class="value">
<el-empty style="padding:0;" v-if="!detail.attachments?.length"
:description="$t('orderDetail.noAttachment')" :image-size="80" />
<div v-else class="files">
<el-button v-for="(f, i) in detail.attachments" :key="i" link type="primary"
class="file-item" @click="viewReport(f.filePath, f.fileName)">
<el-icon>
<Document />
</el-icon>{{ f.fileName }}
</el-button>
</div>
</div>
</div>
</div>
<!-- 第五行:3个(不展示"提出医院",医院端默认就是当前登录医院) -->
<div class="detail-row row-3">
<div class="detail-item">
<div class="label">提出科室</div>
<div class="value">{{ detail.department || '-' }}</div>
</div>
<div class="detail-item">
<div class="label">提出人员</div>
<div class="value">{{ detail.submitter || '-' }}</div>
</div>
<div class="detail-item">
<div class="label">提交时间</div>
<div class="value">{{ detail.createdAt?.split('T').join(' ') || '-' }}</div>
</div>
</div>
</div>
<div class="section" v-if="detail.logs?.length">
<div class="section-title">{{ $t('orderDetail.statusChange') }}</div>
<el-timeline>
<el-timeline-item v-for="(log, i) in detail.logs || []" :key="i" :timestamp="log.createdAt"
placement="top">
<div class="log-title">{{ log.action }} · {{ log.operator }}</div>
<div class="log-remark" v-html="log.remark"></div>
</el-timeline-item>
</el-timeline>
</div>
</div>
<div class="body-footer">
<el-button type="primary" plain style="margin-left:auto" @click="closeDialog">
取消
</el-button>
<el-button v-if="detail.status !== '已完成' && detail.status !== '已取消'" type="primary"
@click="editVisible = true">
{{ $t('btn.edit') }}
</el-button>
</div>
<!-- 编辑工单弹窗(复用医院端 OrderFormDialog,传入 orderId) -->
<OrderFormDialog v-model="editVisible" :order-id="orderId" @success="onEditSuccess" />
<!-- 附件预览弹窗 -->
<FilePreviewDialog :visible="previewVisible" :blob-url="previewUrl" :blob="previewBlob" :file-name="previewName"
:file-type="previewType" @update:visible="(v) => (previewVisible = v)" @close="cleanupPreview"
@download="onPreviewDownload" />
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { Document } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { getHospitalOrderDetail } from "@/service/modular/hospital";
import { getUploadFile } from "@/service/modular/upload";
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
import OrderFormDialog from "./OrderFormDialog.vue";
const { t: $t } = useI18n();
const props = defineProps({
modelValue: { type: Boolean, default: false },
orderId: { type: [String, Number], default: "" },
});
const emit = defineEmits(["update:modelValue", "updated"]);
const loading = ref(false);
const detail = ref({});
const priorityType = (p) => (p === "高" ? "danger" : p === "中" ? "warning" : "info");
const statusType = (s) =>
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[s] || "info";
const loadDetail = async () => {
if (!props.orderId) return;
loading.value = true;
try {
detail.value = await getHospitalOrderDetail(props.orderId);
} finally {
loading.value = false;
}
};
const onOpen = () => {
loadDetail();
};
watch(
() => props.orderId,
() => {
if (props.modelValue) loadDetail();
},
);
const closeDialog = () => emit("update:modelValue", false);
const previewVisible = ref(false);
const previewUrl = ref("");
const previewBlob = ref(null);
const previewName = ref("");
const previewType = ref(""); // 'pdf' | 'image' | ''
const cleanupPreview = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
}
previewBlob.value = null;
previewName.value = "";
previewType.value = "";
};
// 根据文件名/扩展名+blob 的 mime 判断文件类型
const getFileType = (fileName, blob) => {
const name = String(fileName || "");
const ext = name.split(".").pop()?.toLowerCase() || "";
const mime = blob?.type || "";
if (ext === "pdf" || mime === "application/pdf") return "pdf";
if (
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext) ||
(mime && mime.startsWith("image/"))
) {
return "image";
}
return "other";
};
// 浏览器直接下载 blob(指定文件名)
const downloadBlob = (blob, fileName) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || "download";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
};
// FilePreviewDialog 下载按钮回调
const onPreviewDownload = ({ blob, fileName }) => {
if (!blob) return;
downloadBlob(blob, fileName);
};
const viewReport = async (attachmentPath, originalName) => {
if (!attachmentPath) return;
const path = String(attachmentPath);
const downloadName = originalName || path;
try {
const blob = await getUploadFile(path);
const type = getFileType(downloadName, blob);
// PDF / 图片走预览弹窗,其他格式直接下载
if (type === "pdf" || type === "image") {
cleanupPreview();
previewBlob.value = blob;
previewUrl.value = URL.createObjectURL(blob);
previewName.value = downloadName;
previewType.value = type;
previewVisible.value = true;
} else {
downloadBlob(blob, downloadName);
}
} catch (e) {
ElMessage.error(e?.message || $t('msg.failed'));
}
};
// ============== 编辑工单 ==============
const editVisible = ref(false);
// 编辑成功后刷新详情,并通知父组件(list)刷新列表
const onEditSuccess = () => {
editVisible.value = false;
loadDetail();
emit("updated");
};
</script>
<style lang="scss">
.hospital-order-detail-dialog {
max-width: 90vw;
min-width: 70vw;
.body-wrap {
padding: 24px;
.body-header {
margin-bottom: 16px;
}
.detail-container {
.detail-row+.detail-row {
margin-top: 15px;
}
.detail-row {
display: grid;
gap: 15px;
&.row-1 {
grid-template-columns: 1fr;
}
&.row-3 {
grid-template-columns: repeat(3, 1fr);
}
&.row-4 {
grid-template-columns: repeat(4, 1fr);
}
.detail-item {
display: flex;
align-items: center;
gap: 10px;
.label {
font-weight: 400;
font-size: 14px;
color: #1D2129;
width: 4em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
flex-grow: 0;
}
.value {
flex-shrink: 1;
flex-grow: 1;
font-weight: 400;
font-size: 14px;
color: #666666;
padding: 10px;
background: #F8F8F8;
border-radius: 8px 8px 8px 8px;
border: 1px solid #E0E0E0;
.html-content {
background: #fafafa;
padding: 12px;
border-radius: 4px;
min-height: 60px;
line-height: 1.6;
max-width: 100%;
img {
max-width: 100% !important;
}
}
}
}
}
}
}
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.title {
font-size: 16px;
font-weight: 600;
margin-right: 8px;
}
}
.section {
margin-top: 20px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
}
.files {
display: flex;
flex-direction: column;
gap: 8px;
.file-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
.log-title {
font-weight: 600;
}
.log-remark {
color: #606266;
margin-top: 4px;
}
.preview-iframe {
width: 100%;
height: 80vh;
border: 0;
}
.preview-image {
display: block;
max-width: 100%;
max-height: 80vh;
margin: 0 auto;
object-fit: contain;
}
</style>

@ -1,52 +1,30 @@
<template>
<el-dialog :model-value="modelValue" width="720px" :close-on-click-modal="true" destroy-on-close
class="hospital-order-form-dialog" @update:model-value="onDialogUpdate" @closed="onClosed">
<template>
<el-dialog :model-value="modelValue" class="hospital-order-form-dialog" :close-on-click-modal="true"
@update:model-value="(v) => $emit('update:modelValue', v)" @closed="onClosed">
<div class="body-wrap">
<div class="body-header">
<div class="body-title">
{{ isEdit() ? '编辑工单' : $t('modal.submit') }}
</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="onCancel">
<div class="body-title">{{ isEdit() ? '编辑工单' : '新建工单' }}</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="onCancel" />
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input v-model="form.title" :placeholder="$t('placeholder.title')" maxlength="100" show-word-limit />
<div class="color-tag-picker" ref="colorPickerRef">
<div class="color-tag-preview" :style="{ background: form.colorTag || '#dcdfe6' }"
:class="{ 'is-empty': !form.colorTag }" @click.stop="toggleColorPicker"></div>
<div v-show="colorPickerVisible" class="color-tag-dropdown" @click.stop>
<div class="color-tag-title">{{ $t('label.colorTag') }}</div>
<div class="color-tag-options">
<div v-for="c in colorOptions" :key="c.value" class="color-tag-option"
:class="{ active: form.colorTag === c.value }" :style="{ background: c.value }" :title="c.label"
@click="selectColor(c.value)"></div>
</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select v-model="form.serviceType" :placeholder="$t('placeholder.type')" clearable style="width: 240px">
<el-option label="故障报修" value="故障报修" />
<el-option label="业务咨询" value="业务咨询" />
<el-option label="需求变更" value="需求变更" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label-width="0">
<div class="inline-row">
<el-form-item :label="$t('label.priority')" prop="priority" class="inline-item">
<el-radio-group v-model="form.priority">
<el-radio-button label="高">高</el-radio-button>
<el-radio-button label="中">中</el-radio-button>
<el-radio-button label="低">低</el-radio-button>
</el-radio-group>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<!-- 第一行 2 列:服务类型 + 反馈渠道(医院端没有"所属医院") -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select v-model="form.serviceType" :placeholder="$t('placeholder.type')" clearable
style="width: 100%">
<el-option label="故障报修" value="故障报修" />
<el-option label="业务咨询" value="业务咨询" />
<el-option label="需求变更" value="需求变更" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel" class="inline-item">
</el-col>
<el-col :span="12">
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel">
<el-select v-model="form.feedbackchannel" :placeholder="$t('placeholder.feedbackchannel')" clearable
style="width: 200px">
style="width: 100%">
<el-option label="微信群" value="微信群" />
<el-option label="微信" value="微信" />
<el-option label="电话" value="电话" />
@ -56,18 +34,69 @@
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 第二行 3 列:科室 + 提单人 + 提交时间 -->
<el-row :gutter="20">
<el-col :span="8">
<el-form-item :label="$t('label.dept')" prop="department">
<el-input v-model="form.department" :placeholder="$t('placeholder.dept')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input v-model="form.submitter" :placeholder="$t('placeholder.name')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.createat')" prop="createdat">
<el-date-picker v-model="form.createdat" type="datetime" :placeholder="$t('placeholder.createat')"
value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 标题 + 颜色标签 + 优先级 同一行 -->
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input v-model="form.title" :placeholder="$t('placeholder.title')" maxlength="300"
class="title-input">
<template #suffix>
<span class="title-counter"><span :style="{ color: form.colorTag || '#dcdfe6' }">{{ form.title.length
}}</span>/300</span>
<el-popover v-model:visible="colorPickerVisible" :width="180" placement="bottom-end" trigger="click"
:show-arrow="false" popper-class="color-tag-popper">
<template #reference>
<el-badge :color="form.colorTag || '#dcdfe6'" is-dot class="item">
<img src="@/assets/image/admin/orders/sepan.svg" alt="" class="title-sepan" />
</el-badge>
</template>
<div class="color-tag-title">{{ $t('label.colorTag') }}</div>
<div class="color-tag-options">
<div v-for="c in colorOptions" :key="c.value" class="color-tag-option"
:class="{ active: form.colorTag === c.value }" :style="{ background: c.value }" :title="c.label"
@click="selectColor(c.value)"></div>
</div>
</el-popover>
</template>
</el-input>
<div class="priority-wrap">
<div class="priority-label">{{ $t('label.priority') }}</div>
<el-select v-model="form.priority" class="priority-select" :teleported="false">
<template #label="{ label, value }">
<div class="priority-row high" v-if="value === '高'">{{ value }}</div>
<div class="priority-row medium" v-if="value === '中'">{{ value }}</div>
<div class="priority-row low" v-if="value === '低'">{{ value }}</div>
</template>
<el-option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
<span class="priority-option">
<span class="priority-dot" :class="opt.cls"></span>
<span class="priority-option-label">{{ opt.label }}</span>
</span>
</el-option>
</el-select>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.dept')" prop="department">
<el-input v-model="form.department" :placeholder="$t('placeholder.dept')" />
</el-form-item>
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input v-model="form.submitter" :placeholder="$t('placeholder.name')" />
</el-form-item>
<el-form-item :label="$t('label.createat')" prop="createdat">
<el-date-picker v-model="form.createdat" type="datetime" :placeholder="$t('placeholder.createat')"
value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</el-form-item>
<el-form-item :label="$t('label.description')" prop="description">
<div class="rich-editor">
<Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default"
@ -79,12 +108,12 @@
</el-form-item>
<el-form-item class="fujian" :label="$t('label.attachment')">
<el-upload class="upload-wrap" v-model:file-list="form.files" :auto-upload="false" :limit="1"
:on-exceed="handleAttachmentExceed" :on-remove="handleAttachmentRemove" :on-change="handleAttachmentChange">
:on-exceed="handleAttachmentExceed" :on-remove="handleAttachmentRemove"
:on-change="handleAttachmentChange">
<div class="upload-btn">
<div class="left">+ 添加文件</div>
<div class="right">{{ $t('label.attachmentTip') }}</div>
</div>
<template #file="{ file }">
<div class="file-item">
<img class="file-icon" src="@/assets/image/admin/orders/file.svg" alt="">
@ -97,57 +126,12 @@
</el-form-item>
</el-form>
</div>
<div class="body-footer">
<el-button @click="onCancel">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ $t('common.submit') }}
</el-button>
</div>
<!-- 提交工单后 +1 积分奖励弹窗 -->
<el-dialog v-model="rewardVisible" width="360px" :show-close="false" :close-on-click-modal="false"
:close-on-press-escape="false" append-to-body class="reward-dialog">
<div class="reward-content">
<div class="reward-badge">✦ 奖励已发放</div>
<div class="coin-wrapper">
<span class="reward-coin">🪙</span>
</div>
<div class="points-number">
+1 <small>积分</small>
</div>
<div class="reward-title">🎉 到账通知</div>
<div class="reward-sub">1 积分已放入您的钱包</div>
<div class="reward-progress">
<div class="reward-progress-label">
<span>🏆 距兑换「康小虎 AI 吉祥物」</span>
<span class="reward-distance">
<template v-if="rewardDistance > 0">
还差 {{ rewardDistance }} 分
</template>
<template v-else>已达标 🎉</template>
</span>
</div>
<div class="reward-bar-bg">
<div class="reward-bar-fill" :style="{ width: rewardProgress + '%' }"></div>
</div>
<div class="reward-current">
当前 {{ rewardTotalPoints }} 分
</div>
</div>
<div class="reward-actions">
<el-button class="reward-btn reward-btn-received" @click="onRewardClose">
开心收下
</el-button>
<el-button class="reward-btn reward-btn-assets" type="warning" @click="goMyAssets">
查看我的资产 →
</el-button>
</div>
</div>
</el-dialog>
</el-dialog>
</template>
@ -171,8 +155,8 @@ import {
updateHospitalOrder,
getPoints,
} from "@/service/modular/hospital";
const EXCHANGE_GOAL = 500;
import { useRewardResultStore } from "@/stores/rewardResult";
import iconSubmit from "@/assets/image/hospital/ponits/icon-submit-large.svg";
const props = defineProps({
modelValue: { type: Boolean, default: false },
@ -189,6 +173,8 @@ const submitting = ref(false);
// 已加载的原始附件 filePath 集合(用于判断用户是否需要保留原附件)
const originalAttachments = ref([]);
const rewardResultStore = useRewardResultStore();
const getAttachmentFileName = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").pop() || "";
@ -200,7 +186,7 @@ const fillFormFromDetail = (detail) => {
form.title = detail.title || "";
form.serviceType = detail.serviceType || "";
form.priority = detail.priority || "中";
form.feedbackchannel = detail.feedbackChannel || "";
form.feedbackchannel = detail.feedbackChannel || detail.feedbackchannel || "";
form.department = detail.department || "";
form.colorTag = detail.colorTag || "#667eea";
form.description = detail.description || "";
@ -220,6 +206,7 @@ const fillFormFromDetail = (detail) => {
uid: `attachment-${props.orderId}-0`,
name: fileName,
status: "success",
size: first.fileSize || 0,
},
];
return;
@ -290,21 +277,17 @@ const colorOptions = [
{ label: "红", value: "#ff4d4f" },
];
const colorPickerVisible = ref(false);
const colorPickerRef = ref();
const toggleColorPicker = () => {
colorPickerVisible.value = !colorPickerVisible.value;
};
const selectColor = (val) => {
form.colorTag = val;
colorPickerVisible.value = false;
};
const onDocClick = (e) => {
if (!colorPickerVisible.value) return;
const el = colorPickerRef.value;
if (el && !el.contains(e.target)) {
colorPickerVisible.value = false;
}
};
// 优先级可选值(带高/中/低配色 cls)
const priorityOptions = [
{ label: "高", value: "高", cls: "high" },
{ label: "中", value: "中", cls: "medium" },
{ label: "低", value: "低", cls: "low" },
];
const MAX_FILE_SIZE = 50 * 1024 * 1024;
const MAX_PASTE_IMAGE_SIZE = 5 * 1024 * 1024;
@ -426,46 +409,15 @@ const handleEditorCreated = (editor) => {
});
};
// ============== 提交后奖励弹窗 ==============
const rewardVisible = ref(false);
const rewardTotalPoints = ref(0);
const rewardProgress = ref(0);
const rewardDistance = ref(0);
const playCoinAnimation = () => {
nextTick(() => {
const coin = document.querySelector(".reward-coin");
if (!coin) return;
coin.classList.remove("spin");
void coin.offsetWidth;
coin.classList.add("spin");
});
};
const showRewardDialog = async () => {
let total = 0;
// ============== 取最新积分 ==============
const fetchLatestPoints = async () => {
try {
const res = await getPoints();
const data = res?.data ?? res ?? {};
total = Number(data.totalPoints) || 0;
return Number(data.totalPoints) || 0;
} catch (_) {
// 刷新失败不阻塞
return 0;
}
rewardTotalPoints.value = total;
rewardProgress.value = Math.min(100, Math.round((total / EXCHANGE_GOAL) * 100));
rewardDistance.value = Math.max(0, EXCHANGE_GOAL - total);
rewardVisible.value = true;
playCoinAnimation();
};
const onRewardClose = () => {
rewardVisible.value = false;
emit("success");
};
const goMyAssets = () => {
rewardVisible.value = false;
emit("success", { gotoAssets: true });
};
const submit = async () => {
@ -493,7 +445,20 @@ const submit = async () => {
} else {
await createHospitalOrder(formData);
ElMessage.success($t('msg.submitSuccess'));
showRewardDialog();
// 取最新积分,关闭自己,打开全局奖励结果弹窗
const totalPoints = await fetchLatestPoints();
emit("update:modelValue", false);
setTimeout(() => {
rewardResultStore.open({
title: "提交工单",
icon: iconSubmit,
points: 1,
batchGoal: 500,
batchCurrent: totalPoints,
rewardName: "康小虎 AI 吉祥物",
});
}, 100);
emit("success", { isCreate: true });
}
} finally {
submitting.value = false;
@ -505,10 +470,6 @@ const onCancel = () => {
emit("update:modelValue", false);
};
const onDialogUpdate = (val) => {
emit("update:modelValue", val);
};
const onClosed = () => {
// 关闭时重置表单(编辑模式下重新打开会重新加载详情)
form.title = "";
@ -535,8 +496,6 @@ watch(
// 新增模式:默认提交时间为当前时间
form.createdat = nowDatetime();
}
// 绑定全局点击关闭颜色面板
document.addEventListener("click", onDocClick);
},
);
@ -550,7 +509,6 @@ const removePasteListener = () => {
};
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
@ -561,54 +519,92 @@ onBeforeUnmount(() => {
<style lang="scss">
.hospital-order-form-dialog {
.body-wrap {
padding: 24px;
}
.body-header {
margin-bottom: 18px;
}
.rich-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
width: 100%;
overflow: hidden;
background: #fff;
border-radius: 4px;
border: 1px solid #dcdfe6;
overflow: hidden;
}
.rich-editor :deep(.w-e-toolbar) {
background: #fafafa;
border-bottom: 1px solid #dcdfe6;
}
.rich-editor :deep(.w-e-text-container) {
background: #fff;
}
// 标题 + 颜色标签
.priority-row {
&.high {
--current-color: #ff383c;
background: #ffebec;
}
&.medium {
--current-color: #ff8d28;
background: #ffefe2;
}
&.low {
--current-color: #3289fb;
background: #eaf3ff;
}
width: 50px;
height: 30px;
border-radius: 8px 8px 8px 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&::before {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--current-color);
}
color: var(--current-color);
}
// 标题 + 颜色标签 + 优先级 同一行(与 admin 端一致)
.title-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.title-row .el-input {
.title-row .title-input {
flex: 1;
.el-input__wrapper {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
// 优先级 + 反馈渠道同一行展示
.inline-row {
.title-row :deep(.el-input__suffix) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 24px;
width: 100%;
gap: 8px;
}
.inline-row .inline-item {
margin-bottom: 0 !important;
.title-counter {
font-size: 13px;
color: #909399;
margin-right: 10px;
}
.inline-row .inline-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
.title-sepan {
width: 16px;
height: 16px;
display: block;
cursor: pointer;
}
.color-tag-picker {
@ -616,85 +612,96 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.color-tag-preview {
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
// 优先级(与 admin 端一致:左 label + 右 select 拼接)
.priority-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
border-radius: 8px;
height: 40px;
}
.color-tag-preview:hover {
transform: scale(1.08);
.priority-label {
height: 40px;
border-top: 1px solid #ebeef5;
border-bottom: 1px solid #ebeef5;
background: #F9FAFF;
color: #BCBCBC;
font-weight: 400;
font-size: 14px;
white-space: nowrap;
padding: 0 14px;
line-height: 40px;
}
.color-tag-preview.is-empty {
background-image: linear-gradient(135deg,
#f5f5f5 25%,
transparent 25%,
transparent 50%,
#f5f5f5 50%,
#f5f5f5 75%,
transparent 75%);
background-size: 8px 8px;
box-shadow: 0 0 0 1px #dcdfe6;
.priority-select {
width: 110px;
}
.color-tag-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 10;
background: #fff;
border-radius: 10px;
padding: 12px 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 180px;
.priority-select .el-select__wrapper {
background: transparent;
padding: 5px 13px;
display: flex;
justify-content: space-between;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.color-tag-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
.priority-select :deep(.el-select__placeholder) {
color: #303133;
}
.color-tag-options {
display: flex;
.priority-option {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.color-tag-option {
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition:
transform 0.2s ease,
border-color 0.2s ease;
.priority-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.priority-dot.high {
background: #ff383c;
}
.priority-dot.medium {
background: #ff8d28;
}
.color-tag-option:hover {
transform: scale(1.12);
.priority-dot.low {
background: #3289fb;
}
.color-tag-option.active {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
.priority-option-label {
font-size: 14px;
color: #303133;
}
// 限制上传文件预览盒子宽度溢出
:deep(.fujian .el-form-item__content > div) {
width: 100% !important;
}
}
</style>
<style lang="scss">
.hospital-order-form-dialog {
width: 1168px;
overflow: visible;
.body-footer {
border-radius: 0 0 24px 24px;
}
--el-component-size: 40px;
.el-select__wrapper {
height: 40px;
}
// ============== 附件上传(与 admin 端统一) ==============
.fujian {
.upload-wrap {
width: 100%;
@ -753,177 +760,13 @@ onBeforeUnmount(() => {
}
}
}
}
// ============== 提交后奖励弹窗 ==============
:deep(.reward-dialog) {
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
.reward-content {
text-align: center;
padding: 24px 24px 22px;
}
.reward-badge {
display: inline-block;
background: #fef3e6;
color: #c47a2e;
font-size: 12px;
font-weight: 600;
padding: 4px 16px;
border-radius: 40px;
margin-bottom: 8px;
}
.coin-wrapper {
height: 80px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 4px;
}
.reward-coin {
font-size: 64px;
display: inline-block;
filter: drop-shadow(0 8px 16px rgba(255, 193, 7, 0.3));
}
.reward-coin.spin {
animation: rewardCoinSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes rewardCoinSpin {
0% {
transform: rotateY(0deg) scale(0.2);
opacity: 0;
}
60% {
transform: rotateY(720deg) scale(1.2);
opacity: 1;
}
100% {
transform: rotateY(720deg) scale(1);
opacity: 1;
}
}
.points-number {
font-size: 48px;
font-weight: 800;
color: #f5a623;
line-height: 1.1;
margin-top: -4px;
}
.points-number small {
font-size: 20px;
font-weight: 600;
color: #b8863a;
}
.reward-title {
font-size: 22px;
font-weight: 700;
color: #1f140e;
margin: 4px 0 6px;
}
.reward-sub {
font-size: 14px;
color: #7a6a5c;
margin-bottom: 16px;
}
.reward-progress {
background: #f8f4f0;
border-radius: 16px;
padding: 14px 16px;
margin-bottom: 18px;
text-align: left;
}
.reward-progress-label {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #4d3e32;
font-weight: 500;
gap: 8px;
}
.reward-distance {
color: #f5a623;
font-weight: 700;
flex-shrink: 0;
}
.reward-bar-bg {
width: 100%;
height: 6px;
background: #e6ddd5;
border-radius: 10px;
margin-top: 8px;
overflow: hidden;
}
.reward-bar-fill {
height: 100%;
background: linear-gradient(90deg, #f9b84a, #f5a623);
border-radius: 10px;
transition: width 0.6s ease;
}
.reward-current {
margin-top: 8px;
font-size: 12px;
color: #8a7a6a;
text-align: right;
}
.reward-actions {
display: flex;
gap: 10px;
}
.reward-btn {
flex: 1;
padding: 12px 0 !important;
font-size: 14px !important;
font-weight: 600 !important;
border-radius: 60px !important;
}
.reward-btn-received {
background: #f0ebe6 !important;
border-color: #f0ebe6 !important;
color: #4d3e32 !important;
}
.reward-btn-received:hover {
background: #e6ddd5 !important;
border-color: #e6ddd5 !important;
}
.reward-btn-assets {
background: #f9b84a !important;
border-color: #f9b84a !important;
color: #1f140e !important;
box-shadow: 0 4px 14px rgba(249, 184, 74, 0.25);
}
.reward-btn-assets:hover {
background: #f5ac2e !important;
border-color: #f5ac2e !important;
transform: translateY(-1px);
}
// 颜色标签 popover 内容(默认 teleport 到 body,需放非 scoped 样式块)
.color-tag-popper {
padding: 12px 14px !important;
border-radius: 10px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important;
min-width: 180px;
}
</style>

@ -1,216 +1,119 @@
<template>
<div class="order-list">
<el-card shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item :label="$t('table.status')">
<el-select
v-model="filters.status"
:placeholder="$t('common.all')"
clearable
style="width: 140px"
>
<el-option label="待处理" value="待处理" />
<el-option label="已完成" value="已完成" />
<el-option label="取消" value="取消" />
</el-select>
</el-form-item>
<el-form-item :label="$t('table.priority')">
<el-select
v-model="filters.priority"
:placeholder="$t('common.all')"
clearable
style="width: 120px"
>
<el-option label="高" value="高" />
<el-option label="中" value="中" />
<el-option label="低" value="低" />
</el-select>
</el-form-item>
<el-form-item :label="$t('table.type')">
<el-select
v-model="filters.type"
:placeholder="$t('common.all')"
clearable
style="width: 120px"
>
<el-option label="故障问题" value="故障问题" />
<el-option label="使用咨询" value="使用咨询" />
<el-option label="功能需求" value="功能需求" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item :label="$t('placeholder.keyword')">
<el-input
v-model="filters.keyword"
:placeholder="$t('placeholder.keyword')"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item :label="$t('table.time')">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
:start-placeholder="$t('common.startDate')"
:end-placeholder="$t('common.endDate')"
value-format="YYYY-MM-DD"
:shortcuts="dateRangeShortcuts"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{
$t("btn.query")
}}</el-button>
<el-button @click="onReset">{{ $t("btn.reset") }}</el-button>
<el-button type="success" @click="goQuick">{{
$t("btn.quickSubmit")
}}</el-button>
</el-form-item>
</el-form>
<!-- 工单总览 -->
<div class="order-stats">
<div
class="stat-card stat-total"
:class="{ active: activeStat === 'total' }"
@click="onStatClick('total')"
>
<template>
<div class="order-list admin-page-card">
<el-form :inline="true" :model="filters" class="admin-filter-form filter-form">
<el-form-item :label="''">
<el-input v-model="filters.keyword" :placeholder="$t('placeholder.keyword')" clearable style="width: 200px" />
</el-form-item>
<el-form-item :label="''">
<el-select v-model="filters.status" :placeholder="'全部状态'" clearable style="width: 140px">
<el-option label="待处理" value="待处理" />
<el-option label="已完成" value="已完成" />
<!-- <el-option label="取消" value="取消" /> -->
</el-select>
</el-form-item>
<el-form-item :label="''">
<el-select v-model="filters.priority" :placeholder="'全部优先级'" clearable style="width: 120px">
<el-option label="高" value="高" />
<el-option label="中" value="中" />
<el-option label="低" value="低" />
</el-select>
</el-form-item>
<el-form-item :label="''">
<el-select v-model="filters.type" :placeholder="'全部类型'" clearable style="width: 120px">
<el-option label="故障问题" value="故障问题" />
<el-option label="使用咨询" value="使用咨询" />
<el-option label="功能需求" value="功能需求" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item :label="$t('table.time')">
<el-date-picker v-model="dateRange" type="daterange" range-separator="-"
:start-placeholder="$t('common.startDate')" :end-placeholder="$t('common.endDate')" value-format="YYYY-MM-DD"
:shortcuts="dateRangeShortcuts" style="width: 240px" />
</el-form-item>
<el-button type="primary" plain @click="onSearch">{{
$t("btn.query")
}}</el-button>
<el-button @click="onReset">{{ $t("btn.reset") }}</el-button>
<el-button type="primary" @click="goQuick">+ 新建工单</el-button>
</el-form>
<!-- 工单总览 -->
<!-- <div class="order-stats">
<div class="stat-card stat-total" :class="{ active: activeStat === 'total' }" @click="onStatClick('total')">
<div class="stat-icon">📋</div>
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">{{ $t("stat.totalOrders") }}</div>
</div>
<div
class="stat-card stat-pending"
:class="{ active: activeStat === 'pending' }"
@click="onStatClick('pending')"
>
<div class="stat-card stat-pending" :class="{ active: activeStat === 'pending' }"
@click="onStatClick('pending')">
<div class="stat-icon">⏳</div>
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">{{ $t("stat.pendingOrders") }}</div>
</div>
<div
class="stat-card stat-completed"
:class="{ active: activeStat === 'completed' }"
@click="onStatClick('completed')"
>
<div class="stat-card stat-completed" :class="{ active: activeStat === 'completed' }"
@click="onStatClick('completed')">
<div class="stat-icon">✅</div>
<div class="stat-value">{{ stats.completed }}</div>
<div class="stat-label">{{ $t("stat.completedOrders") }}</div>
</div>
<div
class="stat-card stat-high"
:class="{ active: activeStat === 'high' }"
@click="onStatClick('high')"
>
<div class="stat-card stat-high" :class="{ active: activeStat === 'high' }" @click="onStatClick('high')">
<div class="stat-icon">🔴</div>
<div class="stat-value">{{ stats.highPriority }}</div>
<div class="stat-label">{{ $t("stat.highPriorityOrders") }}</div>
</div>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column :label="$t('table.id')" prop="orderNo" width="160" />
<el-table-column :label="$t('table.priority')" width="90">
<template #default="{ row }">
<el-tag :type="priorityType(row.priority)" size="small">{{
row.priority
}}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('table.title')"
prop="title"
min-width="300"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.type')"
prop="serviceType"
width="120"
/>
<el-table-column
:label="$t('table.dept')"
prop="department"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.submitter')"
prop="submitter"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.feedbackchannel')"
prop="feedbackChannel"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.registrar')"
prop="registrarName"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.handler')"
prop="processName"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.handleTime')"
prop="completedAt"
width="170"
>
<template #default="{ row }">
{{ row.completedAt || "-" }}
</template>
</el-table-column>
<el-table-column :label="$t('table.status')" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{
row.status
}}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('table.time')"
prop="createdAt"
width="170"
/>
<el-table-column :label="$t('table.action')" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row.id)">{{
$t("btn.view")
}}</el-button>
<el-button link type="primary" @click="goEdit(row.id)">{{
</div> -->
<el-table class="admin-table" :data="list" v-loading="loading" stripe
header-cell-class-name="admin-table-header-cell">
<el-table-column :label="$t('table.id')" prop="orderNo" width="160" />
<el-table-column :label="$t('table.priority')" width="90">
<template #default="{ row }">
<div class="priority-row" :class="priorityClass(row.priority)">{{ row.priority }}</div>
</template>
</el-table-column>
<el-table-column :label="$t('table.title')" prop="title" min-width="250" show-overflow-tooltip />
<el-table-column :label="$t('table.type')" prop="serviceType" width="auto" />
<el-table-column :label="$t('table.dept')" prop="department" min-width="120" show-overflow-tooltip />
<el-table-column :label="$t('table.submitter')" prop="submitter" min-width="100" show-overflow-tooltip />
<el-table-column :label="$t('table.feedbackchannel')" prop="feedbackChannel" min-width="100"
show-overflow-tooltip />
<el-table-column :label="$t('table.registrar')" prop="registrarName" min-width="100" show-overflow-tooltip />
<el-table-column :label="$t('table.handler')" prop="processName" min-width="100" show-overflow-tooltip />
<el-table-column :label="$t('table.handleTime')" prop="completedAt" width="165">
<template #default="{ row }">
{{ row.completedAt || "-" }}
</template>
</el-table-column>
<el-table-column :label="$t('table.status')" width="100">
<template #default="{ row }">
<div class="status-pill" :class="statusClass(row.status)">{{ row.status }}</div>
</template>
</el-table-column>
<el-table-column :label="$t('table.time')" prop="createdAt" width="160" />
<el-table-column :label="$t('table.action')" min-width="250" fixed="right">
<template #default="{ row }">
<el-button class="purple-sign smaller" plain @click="goDetail(row.id)">{{
$t("btn.view")
}}</el-button>
<el-button class="rosein-sign smaller" plain :class="{ 'disabled-done': row.status === '已完成' }"
@click="goEdit(row.id)">{{
$t("btn.edit")
}}</el-button>
<el-button
link
type="danger"
v-if="row.status === '待处理'"
@click="cancel(row)"
>{{ $t("btn.cancel") }}</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadList"
@current-change="loadList"
/>
</div>
</el-card>
<el-button class="yellow-sign smaller" plain :class="{ 'disabled-done': row.status === '已完成' }"
@click="cancel(row)">{{
$t("btn.cancel") }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="admin-pagination">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="total"
layout="total, sizes, prev, pager, next, jumper" @size-change="loadList" @current-change="loadList" />
</div>
<!-- 工单详情 弹窗(查看操作直接打开弹窗,编辑成功后刷新列表) -->
<OrderDetailDialog v-model="detailVisible" :order-id="currentOrderId" @updated="loadList" />
</div>
</template>
@ -223,6 +126,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { useI18n } from "vue-i18n";
import { getHospitalOrders, cancelHospitalOrder } from "@/service/modular/hospital";
import { useOrderDialogStore } from "@/stores/orderDialog";
import OrderDetailDialog from "./OrderDetailDialog.vue";
const { t: $t } = useI18n();
const route = useRoute();
@ -240,37 +144,44 @@ const filters = reactive({
keyword: "",
});
const priorityType = (p) =>
p === "高" ? "danger" : p === "中" ? "warning" : "info";
const statusType = (s) =>
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[
s
] || "info";
const priorityClass = (p) => {
if (p === "高") return "high";
if (p === "中") return "medium";
if (p === "低") return "low";
return "info";
};
const statusClass = (s) => {
if (s === "已完成") return "done";
if (s === "待处理") return "pending";
if (s === "处理中") return "processing";
if (s === "已取消") return "cancel";
return "info";
};
// 工单总览统计
const stats = reactive({
total: 0,
pending: 0,
completed: 0,
highPriority: 0,
});
// const stats = reactive({
// total: 0,
// pending: 0,
// completed: 0,
// highPriority: 0,
// });
// 当前选中的总览卡片:total | pending | completed | high
const activeStat = ref("total");
const parseStats = (res) => {
let payload = res?.data;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch (_) {
payload = {};
}
}
// const activeStat = ref("total");
// const parseStats = (res) => {
// let payload = res?.data;
// if (typeof payload === "string") {
// try {
// payload = JSON.parse(payload);
// } catch (_) {
// payload = {};
// }
// }
stats.completed = Number(payload?.TotalStatus1 ?? 0);
stats.pending = Number(payload?.TotalStatus2 ?? 0);
stats.total = stats.completed + stats.pending;
stats.highPriority = Number(payload?.TotalPriority ?? 0);
};
// stats.completed = Number(payload?.TotalStatus1 ?? 0);
// stats.pending = Number(payload?.TotalStatus2 ?? 0);
// stats.total = stats.completed + stats.pending;
// stats.highPriority = Number(payload?.TotalPriority ?? 0);
// };
const loadList = async () => {
loading.value = true;
@ -293,7 +204,7 @@ const loadList = async () => {
}
});
total.value = res?.total || 0;
parseStats(res);
// parseStats(res);
} finally {
loading.value = false;
}
@ -387,7 +298,15 @@ const clearListState = () => {
}
};
const goDetail = (id) => router.push(`/hospital/orders/detail/${id}`);
const goDetail = (id) => {
// "查看" 改为弹窗:复用 OrderDetailDialog
currentOrderId.value = id;
detailVisible.value = true;
};
// 工单详情弹窗状态
const currentOrderId = ref("");
const detailVisible = ref(false);
// 工单表单弹窗:通过全局 store 控制(弹窗本体挂在 AdminLayout)
const orderDialogStore = useOrderDialogStore();
@ -457,85 +376,106 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="scss">
@use "@/assets/style/admin-common.scss" as *;
.filter-form {
margin-bottom: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
flex-wrap: wrap;
gap: 12px;
align-items: center;
width: 1800px;
// 工单总览统计
.order-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 16px;
:deep(.el-form-item),
:deep(.el-button) {
margin: 0 0 0 0;
}
:deep(.el-button:last-child) {
margin-left: auto;
}
}
.stat-card {
position: relative;
// 优先级徽章(与 admin/orders/list.vue 保持一致)
.priority-row {
width: 50px;
height: 30px;
border-radius: 8px 8px 8px 8px;
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border-left: 4px solid #d9d9d9;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
cursor: pointer;
user-select: none;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.stat-card.active {
background: #f0f7ff;
box-shadow: 0 4px 14px rgba(24, 144, 255, 0.18);
transform: translateY(-2px);
}
.stat-card.active.stat-pending {
background: #fff7e6;
box-shadow: 0 4px 14px rgba(250, 140, 22, 0.18);
}
.stat-card.active.stat-completed {
background: #f6ffed;
box-shadow: 0 4px 14px rgba(82, 196, 26, 0.18);
}
.stat-card.active.stat-high {
background: #fff1f0;
box-shadow: 0 4px 14px rgba(255, 77, 79, 0.18);
}
.stat-total {
border-left-color: #1890ff;
}
.stat-pending {
border-left-color: #fa8c16;
}
.stat-completed {
border-left-color: #52c41a;
}
.stat-high {
border-left-color: #ff4d4f;
}
.stat-icon {
font-size: 28px;
line-height: 1;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: #333;
line-height: 1.1;
justify-content: center;
gap: 4px;
color: var(--current-color);
font-weight: 500;
font-size: 14px;
&::before {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--current-color);
}
&.high {
--current-color: #ff383c;
background: #ffebec;
}
&.medium {
--current-color: #ff8d28;
background: #ffefe2;
}
&.low {
--current-color: #3289fb;
background: #eaf3ff;
}
&.info {
--current-color: #999999;
background: #f0f0f0;
}
}
.stat-label {
font-size: 13px;
color: #999;
margin-top: 4px;
// 状态徽章
.status-pill {
width: 66px;
height: 30px;
text-align: center;
line-height: 30px;
border-radius: 8px 8px 8px 8px;
font-weight: 500;
font-size: 14px;
&.done {
background: #E8FFEE;
color: #34C759;
}
&.pending {
background: #FFEFE2;
color: #FF8D28;
}
&.processing {
background: #EAF3FF;
color: #3289FB;
}
&.cancel {
background: #F0F0F0;
color: #999999;
}
&.info {
background: #F0F0F0;
color: #666666;
}
}
</style>
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
</style>

@ -28,15 +28,17 @@
<div class="bottom">
<div class="points-card__stats">
<div class="stat-item">
<img src="@/assets/image/hospital/ponits/icon-check-orange.svg" class="stat-icon" alt="" />
<img src="@/assets/image/hospital/ponits/rili-small.svg" class="stat-icon" alt="" />
<span class="stat-label">本月</span>
<strong>+{{ monthPoints }}</strong>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<img src="@/assets/image/hospital/ponits/icon-check-orange.svg" class="stat-icon" alt="" />
<img v-if="todayPoints >= 0" src="@/assets/image/hospital/ponits/arrow-up.svg" class="stat-icon"
alt="" />
<img v-else src="@/assets/image/hospital/ponits/arrow-down.svg" class="stat-icon" alt="" />
<span class="stat-label">今日</span>
<strong>+{{ todayPoints }}</strong>
<strong>{{ todayPoints >= 0 ? '+' + todayPoints : todayPoints }}</strong>
</div>
</div>
</div>
@ -165,7 +167,8 @@
</el-card>
<!-- ========== 兑换表单弹窗 ========== -->
<el-dialog v-model="exchangeVisible" style="padding: 40px;" title="🎁 兑换礼物" width="520px" :close-on-click-modal="false" append-to-body>
<el-dialog v-model="exchangeVisible" style="padding: 40px;" title="🎁 兑换礼物" width="520px"
:close-on-click-modal="false" append-to-body>
<div class="redeem-sub">
填写收件信息,即可兑换「{{ REWARD_NAME }}」
</div>
@ -198,19 +201,15 @@
<!-- ========== 抽奖弹窗 ========== -->
<LotteryDialog v-model="lotteryVisible" :drawable="!summary.isDraw" :batch-goal="50"
:batch-current="Number(summary.rewardCount) || 5" @success="onLotterySuccess"
@view-account="onLotteryViewAccount" />
<!-- ========== 我的资产弹窗 ========== -->
<AssetsDialog v-model="assetsVisible" :total-points="totalPoints" :exchange-goal="EXCHANGE_GOAL"
:reward-name="REWARD_NAME" :today-order="todayBreakdown.order" :today-sign-in="todayBreakdown.signIn"
:today-draw="todayBreakdown.draw" />
:batch-current="Number(summary.rewardCount) || 5" @success="onLotterySuccess" />
<!-- ========== 签到弹窗 ========== -->
<SignInDialog v-model="signInVisible" :signed-in="!!summary.isSignIn" :total-points="totalPoints"
:consecutive-sign-in-days="consecutiveSignInDays" :sign-in-rank="signInRank" :beat-percent="beatPercent"
@success="onSignInSuccess" />
<!-- 我的资产弹窗已迁到 AdminLayout 全局(AssetDialogStore 控制),避免重复弹窗 -->
<!-- ========== 兑换记录弹窗 ========== -->
<el-dialog v-model="redeemLogVisible" class="redeem-log-dialog" append-to-body>
<div class="redeem-body">
@ -283,7 +282,6 @@ import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import SvgIcon from "@/components/SvgIcon/index.vue";
import LotteryDialog from "@/views/hospital/LotteryDialog.vue";
import AssetsDialog from "@/views/hospital/AssetsDialog.vue";
import SignInDialog from "@/views/hospital/SignInDialog.vue";
import { useOrderDialogStore } from "@/stores/orderDialog";
import {
@ -428,8 +426,8 @@ const onSignInSuccess = async () => {
};
// 操作:抽奖(只负责打开弹窗,抽奖逻辑在 LotteryDialog 中)
// “查看我的资产”由 RewardResultDialog 内部触发全局 AssetsDialog,不再需要本地 assetsVisible
const lotteryVisible = ref(false);
const assetsVisible = ref(false);
const handleDraw = () => {
if (summary.value.isDraw) {
ElMessage.info("今日已抽奖,明天再来吧!");
@ -440,10 +438,6 @@ const handleDraw = () => {
const onLotterySuccess = async () => {
await loadAll();
};
const onLotteryViewAccount = () => {
// 抽奖结果页的"查看我的账户" -> 打开资产弹窗
assetsVisible.value = true;
};
// 今日积分明细(按来源分类汇总)
const todayBreakdown = computed(() => {

@ -1,27 +1,33 @@
<template>
<div class="workbench">
<div class="workbench admin-page-card">
<!-- 时间筛选 -->
<div class="filter-bar">
<el-radio-group v-model="period" size="small" @change="loadStats">
<el-radio-button label="month">{{ $t('common.thisMonth') }}</el-radio-button>
<el-radio-button label="quarter">{{ $t('common.thisQuarter') }}</el-radio-button>
<el-radio-button label="year">{{ $t('common.thisYear') }}</el-radio-button>
<el-radio-button label="custom">{{ $t('common.customDate') }}</el-radio-button>
</el-radio-group>
<el-date-picker v-if="period === 'custom'" v-model="dateRange" type="daterange" range-separator="-"
:start-placeholder="$t('common.startDate')" :end-placeholder="$t('common.endDate')" value-format="YYYY-MM-DD"
:shortcuts="dateRangeShortcuts" size="small" style="margin-left: 12px" @change="loadStats" />
<div class="period-bar">
<div class="period-tabs">
<el-button v-for="t in periodTabs" :key="t.value"
:class="['period-tab', { active: period === t.value && !isCustom }]" plain @click="onPickPeriod(t.value)">
{{ t.label }}
</el-button>
<el-button :class="['period-tab', { active: isCustom }]" plain @click="onPickCustom">{{
$t('common.customDate')
}}</el-button>
</div>
<div v-if="isCustom" class="custom-range">
<el-date-picker v-model="dateRange" type="daterange" range-separator="-"
:start-placeholder="$t('common.startDate')" :end-placeholder="$t('common.endDate')"
value-format="YYYY-MM-DD" style="width: 240px" />
<el-button type="primary" class="search-btn" plain @click="onSearch">查询</el-button>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card" v-for="card in cards" :key="card.label" @click="goToOrders(card)">
<div class="stat-icon" :style="{ background: card.bg }">
<el-icon :size="22" :color="card.color">
<component :is="card.icon" />
</el-icon>
<div class="stat-card" v-for="card in cards" :key="card.key" @click="goToOrders(card)">
<div class="stat-icon">
<img :src="card.icon" alt="" />
</div>
<div class="stat-info">
<div class="right">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</div>
@ -29,22 +35,30 @@
</div>
<!-- 趋势图 -->
<el-card class="trend-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">{{ $t('chart.title') }}</span>
<div class="chart-switch">
<span class="switch-item" :class="{ active: chartType === 'bar' }" @click="chartType = 'bar'">
<i class="switch-icon bar" /> {{ $t('chart.bar') }}
</span>
<span class="switch-item" :class="{ active: chartType === 'line' }" @click="chartType = 'line'">
<i class="switch-icon line" /> {{ $t('chart.line') }}
</span>
</div>
<div class="trend-card">
<div class="card-header">
<span class="border-title">{{ $t('chart.title') }}</span>
<div class="chart-switch" :class="{ 'is-line': chartType === 'line' }">
<span class="switch-item" :class="{ active: chartType === 'bar' }" @click="chartType = 'bar'">
{{ $t('chart.bar') }}
</span>
<span class="switch-item" :class="{ active: chartType === 'line' }" @click="chartType = 'line'">
{{ $t('chart.line') }}
</span>
</div>
</div>
<v-chart ref="chartRef" class="chart" :option="chartOption" autoresize />
<div class="chart-legend">
<div class="legend-item">
<span class="legend-dot legend-dot--submit" />
<span class="legend-text">{{ $t('chart.submit') }}</span>
</div>
</template>
<v-chart ref="chartRef" class="chart" :option="chartOption" autoresize />
</el-card>
<div class="legend-item">
<span class="legend-dot legend-dot--complete" />
<span class="legend-text">{{ $t('chart.complete') }}</span>
</div>
</div>
</div>
</div>
</template>
@ -53,7 +67,10 @@ import dayjs from "dayjs";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { dateRangeShortcuts } from "@/utils/dateShortcuts";
import { useRouter } from "vue-router";
import { Tickets, Check, Bell, Warning } from "@element-plus/icons-vue";
import iconAll from "@/assets/image/admin/orders/all.svg";
import iconPending from "@/assets/image/admin/orders/pending.svg";
import iconComplete from "@/assets/image/admin/orders/complete.svg";
import iconHigh from "@/assets/image/admin/orders/high.svg";
import VChart from "vue-echarts";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
@ -72,10 +89,33 @@ use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, Legen
const { t: $t } = useI18n();
// 时间筛选 tab(不包含"本周",自定义点查询才触发)
const periodTabs = [
{ label: $t('common.thisMonth'), value: "month" },
{ label: $t('common.thisQuarter'), value: "quarter" },
{ label: $t('common.thisYear'), value: "year" },
];
const period = ref("month");
const isCustom = ref(false);
const dateRange = ref([]);
const chartType = ref("line");
const onPickPeriod = (val) => {
isCustom.value = false;
dateRange.value = [];
period.value = val;
loadStats();
};
const onPickCustom = () => {
isCustom.value = true;
};
const onSearch = () => {
loadStats();
};
const stats = ref({
totalOrders: 0,
completedOrders: 0,
@ -90,11 +130,12 @@ const chartRef = ref(null);
const overlaySize = ref({ w: 0, h: 0 });
const stripeTargets = ref([]);
// 顺序、文案与 src/views/admin/orders/list.vue 的 .order-stats 保持一致
const cards = computed(() => [
{ key: "total", label: $t('stat.total'), value: stats.value.totalOrders, icon: Tickets, color: "#5B8FF9", bg: "rgba(91,143,249,0.12)" },
{ key: "completed", label: $t('stat.completed'), value: stats.value.completedOrders, icon: Check, color: "#5AD8A6", bg: "rgba(90,216,166,0.12)" },
{ key: "pending", label: $t('stat.pending'), value: stats.value.pendingOrders, icon: Bell, color: "#F6BD16", bg: "rgba(246,189,22,0.12)" },
{ key: "highPriority", label: $t('stat.highPriority'), value: stats.value.highPriorityOrders, icon: Warning, color: "#F4664A", bg: "rgba(244,102,74,0.12)" },
{ key: "total", label: $t('stat.totalOrders'), value: stats.value.totalOrders, icon: iconAll },
{ key: "pending", label: $t('stat.pendingOrders'), value: stats.value.pendingOrders, icon: iconPending },
{ key: "completed", label: $t('stat.completedOrders'), value: stats.value.completedOrders, icon: iconComplete },
{ key: "highPriority", label: $t('stat.highPriorityOrders'), value: stats.value.highPriorityOrders, icon: iconHigh },
]);
const allStripeLines = computed(() => {
@ -125,10 +166,10 @@ const goToOrders = (card) => {
};
// ==================== ECharts 配置 ====================
const BAR_W = 14;
const CORNER_R = BAR_W / 8;
const WRAP_THICK = BAR_W / 10;
const WRAP_BORDER = "#eff2fe";
const BAR_W = 59;
const CORNER_R = 8;
const WRAP_THICK = 8;
const WRAP_BORDER = "#EFF2FE";
const buildLinearGradient = (top, bottom) => ({
type: "linear",
@ -146,7 +187,10 @@ const chartOption = computed(() => {
const complete = data.map((d) => d.completeCount);
const isLine = chartType.value === "line";
const isBar = chartType.value === "bar";
const showSlider = dates.length > 20;
// > 7 组才显示滚动条;用 startValue/endValue 锁定最后 7 组,避免百分比误差
const showSlider = dates.length > 7;
const sliderStartValue = showSlider ? dates[dates.length - 7] : undefined;
const sliderEndValue = showSlider ? dates[dates.length - 1] : undefined;
const maxValue = Math.max(0, ...submit, ...complete);
const yMax = maxValue > 0 ? Math.ceil(maxValue / 2) * 2 : 4;
@ -167,6 +211,15 @@ const chartOption = computed(() => {
lineStyle: { color: submitTopColor, width: 2 },
itemStyle: { color: submitTopColor },
areaStyle: { color: "rgba(50,137,251,0.08)" },
label: {
show: true,
position: "top",
distance: 8,
color: submitTopColor,
fontWeight: 400,
fontSize: 14,
formatter: (p) => p.value,
},
},
{
name: $t('chart.complete'),
@ -178,6 +231,15 @@ const chartOption = computed(() => {
lineStyle: { color: completeTopColor, width: 2 },
itemStyle: { color: completeTopColor },
areaStyle: { color: "rgba(52,199,89,0.08)" },
label: {
show: true,
position: "top",
distance: 8,
color: completeTopColor,
fontWeight: 400,
fontSize: 14,
formatter: (p) => p.value,
},
},
];
@ -205,21 +267,13 @@ const chartOption = computed(() => {
});
return {
grid: { left: 30, right: 70, top: 50, bottom: showSlider ? 60 : 30 },
grid: { left: 30, right: 30, top: 50, bottom: showSlider ? 60 : 30 },
tooltip: { trigger: "axis" },
legend: {
orient: "vertical",
right: 10,
top: "middle",
itemWidth: 10,
itemHeight: 10,
itemGap: 16,
textStyle: { fontSize: 12, color: "#606266" },
},
xAxis: {
type: "category",
data: dates,
boundaryGap: !isLine,
// 折线/柱状图统一带 boundaryGap,让首末坐标点/柱距边留出与柱状图一致的间距
boundaryGap: true,
...(isBar ? { barCategoryGap: "7%" } : {}),
axisLine: { lineStyle: { color: "#e4e7ed" } },
},
@ -231,10 +285,24 @@ const chartOption = computed(() => {
axisLine: { show: false },
splitLine: { lineStyle: { color: "#f0f2f5" } },
},
dataZoom: [
{ type: "inside", xAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: false },
...(showSlider ? [{ type: "slider", xAxisIndex: 0, bottom: 10, height: 20, start: 0, end: 100, showDetail: false }] : []),
],
dataZoom: showSlider
? [
{
type: "slider",
xAxisIndex: 0,
bottom: 0,
height: 14,
startValue: sliderStartValue,
endValue: sliderEndValue,
zoomLock: true,
showDetail: false,
showDataShadow: false,
fillerColor: "rgba(50,137,251,0.12)",
borderColor: "transparent",
backgroundColor: "#F2F3F5",
},
]
: [],
series: isLine ? lineSeries : [
{ ...makeBarSeries(submit, submitTopColor, submitWrapColor), name: $t('chart.submit') },
{ ...makeBarSeries(complete, completeTopColor, completeWrapColor), name: $t('chart.complete') },
@ -324,10 +392,13 @@ const buildMockTrendData = (startDate, endDate) => {
};
const loadStats = async () => {
const params = { period: period.value };
if (period.value === "custom" && dateRange.value?.length === 2) {
// 自定义模式以 isCustom 为准(自定义需点查询才进这里,与 score.vue 一致)
const params = {};
if (isCustom.value && dateRange.value?.length === 2) {
params.start = dateRange.value[0];
params.end = dateRange.value[1];
} else if (!isCustom.value) {
params.period = period.value;
}
try {
const data = await getWorkbench(params);
@ -356,57 +427,74 @@ onMounted(loadStats);
gap: 16px;
}
.filter-bar {
background: #fff;
padding: 12px 16px;
border-radius: 6px;
}
// 统计卡片:与 src/views/admin/orders/list.vue .order-stats 同款(workbench 无 active 态,省略相关样式)
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 5px;
}
.stat-cards .stat-card {
background: #fff;
border-radius: 6px;
padding: 20px;
.stat-card {
position: relative;
display: flex;
align-items: center;
gap: 16px;
gap: 18px;
padding: 18px 24px;
background: #fff;
border-radius: 16px 16px 16px 16px;
border: 1px solid #e0e0e0;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s;
user-select: none;
}
.stat-cards .stat-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.stat-cards .stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.stat-icon img {
width: 48px;
height: 48px;
object-fit: contain;
}
.stat-cards .stat-info .stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
// 右侧文字区(value + label 垂直堆叠,column-reverse 让 value 在 label 上方)
.stat-card .right {
display: flex;
flex-direction: column-reverse;
gap: 2px;
min-width: 0;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: #333;
line-height: 1.1;
}
.stat-cards .stat-info .stat-label {
.stat-label {
font-size: 13px;
color: #909399;
color: #999;
margin-top: 4px;
}
.trend-card :deep(.el-card__header) {
padding: 12px 16px;
.trend-card {
border-radius: 16px 16px 16px 16px;
border: 1px solid #E0E0E0;
padding: 21px 16px;
}
.trend-card .card-header {
@ -415,70 +503,104 @@ onMounted(loadStats);
justify-content: space-between;
}
.trend-card .card-header .card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
// 类 el-switch 分段控件:外层浅紫底,内项平分,激活态由 ::before 滑动白块承载(带过渡动画)
.trend-card .card-header .chart-switch {
position: relative;
display: inline-flex;
background: #f5f7fa;
border-radius: 4px;
padding: 2px;
width: 220px;
height: 31px;
padding: 4px;
background: #F9FAFF;
border-radius: 8px;
overflow: hidden;
}
.trend-card .card-header .chart-switch::before {
content: "";
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: #FFFFFF;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 0;
}
.trend-card .card-header .chart-switch.is-line::before {
transform: translateX(100%);
}
.trend-card .card-header .chart-switch .switch-item {
flex: 1;
height: 100%;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
color: #606266;
justify-content: center;
font-size: 14px;
font-weight: 400;
color: #666666;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
user-select: none;
position: relative;
z-index: 1;
transition: color 0.3s ease;
}
.trend-card .card-header .chart-switch .switch-item .switch-icon {
width: 10px;
height: 10px;
background: #5AD8A6;
border-radius: 1px;
.trend-card .card-header .chart-switch .switch-item:hover {
color: #3289FB;
}
.trend-card .card-header .chart-switch .switch-item .switch-icon.line {
background: #5B8FF9;
.trend-card .card-header .chart-switch .switch-item.active {
color: #3289FB;
}
.trend-card .card-header .chart-switch .switch-item:hover {
color: #409eff;
.trend-card .chart-wrap {
position: relative;
}
.trend-card .card-header .chart-switch .switch-item.active {
background: #409eff;
color: #fff;
.trend-card .chart {
height: 340px;
}
.trend-card .card-header .chart-switch .switch-item.active .switch-icon {
background: #fff;
// 自定义图例:水平居中,色块 + 文字两部分,提交/完成配色与图表系列保持一致
.trend-card .chart-legend {
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
padding-top: 12px;
}
.trend-card .card-header .chart-switch .switch-item.active .switch-icon.line {
background: #fff;
.trend-card .chart-legend .legend-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.trend-card .card-header .chart-switch .switch-item.active:hover {
color: #fff;
.trend-card .chart-legend .legend-dot {
display: inline-block;
width: 28px;
height: 16px;
border-radius: 4px;
}
.trend-card .chart-wrap {
position: relative;
.trend-card .chart-legend .legend-dot--submit {
background: #3289FB;
}
.trend-card .chart {
height: 340px;
.trend-card .chart-legend .legend-dot--complete {
background: #34C759;
}
.trend-card .chart-legend .legend-text {
font-size: 14px;
font-weight: 400;
color: #171F2A;
line-height: 1;
}
.trend-card .stripe-overlay {
@ -487,4 +609,75 @@ onMounted(loadStats);
left: 0;
pointer-events: none;
}
</style>
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
.workbench {
.filter-bar {
background: #fff;
.period-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
.period-tabs {
display: flex;
gap: 10px;
}
.period-tab {
background: #FFFFFF !important;
border-radius: 8px 8px 8px 8px !important;
border: 1px solid #E0E0E0 !important;
font-weight: normal !important;
font-size: 16px !important;
color: #1D2129 !important;
line-height: 22px !important;
width: auto !important;
height: auto !important;
padding: 10px 24px !important;
&:hover,
&.active {
background: #3289FB !important;
border-color: #3289FB !important;
color: #fff !important;
}
}
.search-btn {
font-size: 16px !important;
line-height: 22px !important;
width: auto !important;
height: auto !important;
padding: 10px 24px !important;
&:hover,
&.active {
background: #3289FB !important;
border-color: #3289FB !important;
color: #fff !important;
}
}
.custom-range {
display: flex;
align-items: center;
gap: 10px;
.el-date-editor {
background: #FFFFFF !important;
border-radius: 8px 8px 8px 8px !important;
border: 1px solid #E0E0E0 !important;
overflow: hidden;
height: 44px !important;
}
}
}
}
}
</style>
Loading…
Cancel
Save