医院端上传

master
2358328281@qq.com 12 hours ago
parent 14225782c5
commit 4db755c75d

@ -29,6 +29,7 @@
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item command="theme">更换主题</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
@ -39,21 +40,79 @@
<main class="hospital-main">
<slot />
</main>
<!-- 更换主题弹窗 -->
<el-dialog
v-model="themeDialogVisible"
title="🎨 更换主题"
width="540px"
:close-on-click-modal="false"
align-center
destroy-on-close
>
<div class="theme-dialog">
<div class="theme-grid">
<div
v-for="t in presetThemes"
:key="t.value"
class="theme-card"
:class="{ active: themeStore.themeValue === t.value }"
@click="handlePresetTheme(t.value)"
>
<div class="theme-preview" :style="{ background: t.preview }"></div>
<div class="theme-name">{{ t.name }}</div>
<div class="theme-desc">{{ t.desc }}</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { computed } from "vue";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
import NavBar from "@/components/navBar/NavBar.vue";
import SvgIcon from "@/components/SvgIcon/index.vue";
import { useUserStore } from "@/stores/api/user";
import { useThemeStore } from "@/stores/theme";
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const themeStore = useThemeStore();
//
const themeDialogVisible = ref(false);
// scss
const presetThemes = [
{
value: "blue",
name: "穹宇蓝",
desc: "深邃星空,科技感十足",
preview: "linear-gradient(135deg, #6c7cf3 0%, #5868e6 100%)",
},
{
value: "purple",
name: "星轨紫",
desc: "神秘优雅,梦幻氛围",
preview: "linear-gradient(135deg, #A06CFC 0%, #6B2EDB 100%)",
},
{
value: "green",
name: "青峦绿",
desc: "清新自然,宁静致远",
preview: "linear-gradient(135deg, #34B693 0%, #228F72 100%)",
},
];
//
const handlePresetTheme = (value) => {
themeStore.setTheme(value);
};
//
const topRoutes = computed(() => {
@ -74,6 +133,10 @@ const handleCommand = async (cmd) => {
router.push("/hospital/password");
return;
}
if (cmd === "theme") {
themeDialogVisible.value = true;
return;
}
if (cmd === "logout") {
try {
await ElMessageBox.confirm("确定要退出登录吗?", "提示", {
@ -104,7 +167,7 @@ const handleCommand = async (cmd) => {
display: flex;
align-items: center;
padding: 0 24px;
background: linear-gradient(135deg, #8b9ff5 0%, #6c7cf3 100%);
background: linear-gradient(135deg, var(--brand-color-hover) 0%, var(--brand-color) 100%);
color: #fff;
.brand {
@ -162,4 +225,56 @@ const handleCommand = async (cmd) => {
padding: 16px;
}
}
.theme-dialog {
padding: 4px 4px 0;
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.theme-card {
border: 2px solid #e8e8e8;
border-radius: 12px;
padding: 14px;
cursor: pointer;
transition: all 0.25s ease;
text-align: center;
background: #fff;
&:hover {
border-color: var(--brand-color);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
&.active {
border-color: var(--brand-color);
background: var(--brand-color-light);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
}
}
.theme-preview {
height: 56px;
border-radius: 8px;
margin-bottom: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.theme-name {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.theme-desc {
font-size: 12px;
color: #999;
min-height: 18px;
}
}
</style>

@ -384,7 +384,7 @@ export default {
points: {
title: "🎁 我的积分",
unit: "分",
rewards: "已获得15次服务奖励",
rewards: "已获得 {count} 次服务奖励",
detail: "积分明细",
points: "积分",
pointsTime: "时间",
@ -392,6 +392,73 @@ export default {
pointsOrder: "工单号",
pointsChange: "积分变动",
pointsDesc: "说明",
refresh: "刷新",
rule: "规则",
availablePoints: "💰 当前可用积分",
rewardBadge: "已获 {count} 次奖励",
thisMonth: "本月",
today: "今日",
goalTitle: "兑换进度",
distanceGoal: "还差 {count} 分",
goalReached: "已达标 🎉",
rewardName: "康小虎 AI 吉祥物",
rewardDesc: "限量款 · 智能语音互动",
goExchange: "🎁 去兑换",
shortOfExchange: "还差 {count} 分",
// 操作区
signIn: "签到",
signInTip: "+1 积分",
lottery: "抽奖",
lotteryTip: "+1 ~ 10 积分",
submitOrder: "提交工单",
submitOrderTip: "+1 积分",
// 历史
historyTitle: "积分变动记录",
viewRedeemLog: "查看兑换记录",
noRecords: "暂无记录",
pageInfo: "第 {page} / {total} 页",
prev: "上一页",
next: "下一页",
// 兑换表单
redeemTitle: "兑换礼物",
redeemSub: "填写收件信息,即可兑换「康小虎 AI 吉祥物」",
redeemCost: "🎯 兑换消耗",
redeemCostValue: "-{count} 积分",
recipientName: "收件人",
recipientPhone: "手机号",
recipientAddress: "收件地址",
pleaseInputRecipientName: "请输入收件人姓名",
pleaseInputRecipientPhone: "请输入手机号码",
pleaseInputRecipientAddress: "请输入详细收件地址",
pleaseInputValidPhone: "请输入有效的手机号码",
confirmRedeem: "确认兑换",
cancelRedeem: "取消",
// 兑换记录
redeemLogTitle: "我的兑换记录",
redeemLogSub: "已成功兑换的「康小虎 AI 吉祥物」清单",
redeemLogEmpty: "暂无兑换记录",
redeemLogEmptyTip: "快去积攒积分兑换「康小虎」吧!",
redeemedStatus: "已兑换",
redeemLogRemark: "备注",
// 提示
signedToday: "今日已签到,明天再来吧!",
signSuccess: "签到成功! +1 积分 🎉",
lotterySuccess: "🎉 抽中 {count} 积分!",
submitSuccessTip: "✅ 提交工单 +1 积分",
pointsNotEnough: "积分不足,还需要 {count} 分才能兑换",
pointsNotEnoughShort: "积分不足,无法兑换",
redeemSuccess: "🎊 兑换成功!「康小虎 AI 吉祥物」已发货",
drawnToday: "今日已抽奖,明天再来吧!",
refreshSuccess: "已刷新",
ruleTip: "📖 签到 +1 · 抽奖 +1~10 · 提交工单 +1",
redeemSubmitted: "兑换请求已提交",
// 提交工单奖励
rewardTitle: "🎉 到账通知",
rewardBadge: "✦ 奖励已发放",
rewardDeposited: "1 积分已放入您的钱包",
rewardReceived: "开心收下",
viewMyAssets: "查看我的资产",
distanceToReward: "距兑换「康小虎 AI 吉祥物」",
},
// 报告页面
report: {

@ -42,6 +42,12 @@ export const constantRoutes = [
component: () => import("@/views/hospital/orders/quick.vue"),
meta: { title: "快速提交", icon: "EditPen" },
},
{
path: "orders/edit/:id",
name: "HospitalOrderEdit",
component: () => import("@/views/hospital/orders/quick.vue"),
meta: { title: "编辑工单", hidden: true },
},
{
path: "points",
name: "HospitalPoints",

@ -12,11 +12,11 @@ export const getHospitalOrders = (params) => {
export const getHospitalOrderDetail = (id) => {
return kcRequest.request(`/hospital/orders/${id}`, "get");
};
export const createHospitalOrder = (data) => {
return kcRequest.request(`/hospital/orders`, "post", data);
export const createHospitalOrder = (formData) => {
return kcRequest.uploadFile(`/hospital/orders`, formData);
};
export const updateHospitalOrder = (id, data) => {
return kcRequest.request(`/hospital/orders/${id}`, "put", data);
export const updateHospitalOrder = (id, formData) => {
return kcRequest.uploadFile(`/hospital/orders/${id}`, formData, true, "PUT");
};
export const cancelHospitalOrder = (id) => {
return kcRequest.request(`/hospital/orders/${id}`, "delete");
@ -29,3 +29,21 @@ export const getPoints = () => {
export const getPointsDetails = (params) => {
return kcRequest.request(`/hospital/points/details`, "get", params);
};
export const signIn = (data) => {
return kcRequest.request(`/hospital/signin`, "post", data);
};
export const draw = () => {
return kcRequest.request(`/hospital/draw`, "post");
};
export const redeem = (data) => {
return kcRequest.request(`/hospital/redeem`, "post", data);
};
export const getRedeems = (params) => {
return kcRequest.request(`/hospital/redeems`, "get", params);
};
// ============== 账号 ==============
// 当前登录用户修改自己的密码401 = 旧密码错误,使用 silent 避免跳登录页)
export const changePassword = (data) => {
return kcRequest.request(`/hospital/accounts/change-password`, "put", data, true, "json", true);
};

@ -8,6 +8,11 @@ export const uploadAttachments = (orderNo, files) => {
return kcRequest.uploadFile(`/upload`, fd);
};
// 下载/预览上传的文件医院验收报告等GET返回 Blob
export const getUploadFile = (fileName) => {
return kcRequest.downloadFile(`/upload/${fileName}`);
};
// 获取附件预览/下载地址
export const getAttachmentUrl = (fileName) => {
const base = import.meta.env.DEV ? "/api" : window.location.origin;

@ -12,16 +12,24 @@
<span class="title">{{ detail.title }}</span>
<el-tag :type="priorityType(detail.priority)" size="small">{{ detail.priority }}</el-tag>
<el-tag :type="statusType(detail.status)" size="small">{{ detail.status }}</el-tag>
<el-button
v-if="detail.status !== '已完成' && detail.status !== '已取消'"
type="primary"
size="small"
style="margin-left:auto"
@click="goEdit"
>
{{ $t('btn.edit') }}
</el-button>
</div>
</template>
<el-descriptions :column="3" border>
<el-descriptions-item :label="$t('table.id')">{{ detail.orderNo }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.type')">{{ detail.serviceType }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.time')">{{ detail.createdAt }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.time')">{{ formatTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.submitter')">{{ detail.submitter }}</el-descriptions-item>
<el-descriptions-item :label="$t('label.dept')">{{ detail.department }}</el-descriptions-item>
<el-descriptions-item :label="$t('label.colorTag')">{{ detail.colorTag }}</el-descriptions-item>
</el-descriptions>
<div class="section">
@ -33,16 +41,16 @@
<div class="section-title">{{ $t('label.attachment') }} ({{ (detail.attachments || []).length }})</div>
<el-empty v-if="!detail.attachments?.length" :description="$t('orderDetail.noAttachment')" :image-size="80" />
<div v-else class="files">
<el-link
<el-button
v-for="(f, i) in detail.attachments"
:key="i"
:href="getAttachmentUrl(f.fileName)"
target="_blank"
link
type="primary"
class="file-item"
@click="viewReport(f.filePath, f.fileName)"
>
<el-icon><Document /></el-icon>{{ f.fileName }}
</el-link>
</el-button>
</div>
</div>
@ -56,11 +64,22 @@
placement="top"
>
<div class="log-title">{{ log.action }} · {{ log.operator }}</div>
<div class="log-remark">{{ log.remark }}</div>
<div class="log-remark" v-html="log.remark"></div>
</el-timeline-item>
</el-timeline>
</div>
</el-card>
<FilePreviewDialog
:visible="previewVisible"
:blob-url="previewUrl"
:blob="previewBlob"
:file-name="previewName"
:file-type="previewType"
@update:visible="(v) => (previewVisible = v)"
@close="cleanupPreview"
@download="onPreviewDownload"
/>
</div>
</template>
@ -68,9 +87,13 @@
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft, Document } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { getHospitalOrderDetail } from "@/service/modular/hospital";
import { getAttachmentUrl } from "@/service/modular/upload";
import { getUploadFile } from "@/service/modular/upload";
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
@ -80,6 +103,8 @@ const priorityType = (p) => (p === "高" ? "danger" : p === "中" ? "warning" :
const statusType = (s) =>
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[s] || "info";
const formatTime = (val) => (val ? String(val).split("T").join(" ") : "");
const loadDetail = async () => {
loading.value = true;
try {
@ -90,6 +115,80 @@ const loadDetail = async () => {
};
const goBack = () => router.back();
const goEdit = () => router.push(`/hospital/orders/edit/${route.params.id}`);
// ============== / ==============
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'));
}
};
onMounted(loadDetail);
</script>
@ -128,6 +227,10 @@ onMounted(loadDetail);
border-radius: 4px;
min-height: 60px;
line-height: 1.6;
:deep(img) {
max-width: 100% !important;
}
}
.files {
display: flex;

@ -1,53 +1,198 @@
<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-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-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-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-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"
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-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')"
>
<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-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-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-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.title')" prop="title" min-width="200" show-overflow-tooltip />
<el-table-column :label="$t('table.type')" prop="serviceType" width="120" />
<el-table-column :label="$t('table.priority')" width="90">
<template #default="{ row }">
<el-tag :type="priorityType(row.priority)" size="small">{{ row.priority }}</el-tag>
<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>
<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="120" fixed="right">
<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.detail') }}</el-button>
<el-button link type="danger" v-if="row.status === '待处理'" @click="cancel(row)">{{ $t('btn.cancel') }}</el-button>
<el-button link type="primary" @click="goDetail(row.id)">{{
$t("btn.view")
}}</el-button>
<el-button link type="primary" @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>
@ -68,66 +213,191 @@
</template>
<script setup>
import dayjs from "dayjs";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useI18n } from "vue-i18n";
import { getHospitalOrders, cancelHospitalOrder } from "@/service/modular/hospital";
const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const filters = reactive({ status: "", priority: "", keyword: "" });
const dateRange = ref([]);
const filters = reactive({
status: "",
priority: "",
type: "",
keyword: "",
});
const priorityType = (p) => (p === "高" ? "danger" : p === "中" ? "warning" : "info");
const priorityType = (p) =>
p === "高" ? "danger" : p === "中" ? "warning" : "info";
const statusType = (s) =>
({
待处理: "warning",
处理中: "primary",
已完成: "success",
已取消: "info",
})[s] || "info";
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[
s
] || "info";
//
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 = {};
}
}
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;
try {
const res = await getHospitalOrders({
page: page.value,
pageSize: pageSize.value,
...filters,
});
const params = { page: page.value, pageSize: pageSize.value, ...filters };
if (dateRange.value?.length === 2) {
params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1];
}
const res = await getHospitalOrders(params);
list.value = res?.list || [];
list.value.forEach((item) => {
if (item.createdAt) {
item.createdAt = dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss");
}
if (item.completedAt) {
item.completedAt = dayjs(item.completedAt).format(
"YYYY-MM-DD HH:mm:ss",
);
}
});
total.value = res?.total || 0;
parseStats(res);
} finally {
loading.value = false;
}
};
//
const onStatClick = (type) => {
activeStat.value = type;
if (type === "total") {
filters.status = "";
filters.priority = "";
} else if (type === "pending") {
filters.status = "待处理";
filters.priority = "";
} else if (type === "completed") {
filters.status = "已完成";
filters.priority = "";
} else if (type === "high") {
filters.status = "";
filters.priority = "高";
}
page.value = 1;
loadList();
};
const onSearch = () => {
page.value = 1;
loadList();
};
const onReset = () => {
filters.status = "";
filters.priority = "";
filters.keyword = "";
Object.assign(filters, {
status: "",
priority: "",
type: "",
keyword: "",
});
dateRange.value = [];
activeStat.value = "total";
onSearch();
};
// sessionStorage key/
const LIST_STATE_KEY = "hospital_orders_list_state";
const saveListState = () => {
try {
const state = {
filters: { ...filters },
dateRange: Array.isArray(dateRange.value) ? [...dateRange.value] : [],
page: page.value,
pageSize: pageSize.value,
activeStat: activeStat.value,
};
sessionStorage.setItem(LIST_STATE_KEY, JSON.stringify(state));
} catch (_) {
//
}
};
const restoreListState = () => {
try {
const raw = sessionStorage.getItem(LIST_STATE_KEY);
if (!raw) return;
const state = JSON.parse(raw);
if (!state || typeof state !== "object") return;
if (state.filters && typeof state.filters === "object") {
Object.assign(filters, state.filters);
}
if (Array.isArray(state.dateRange)) {
dateRange.value = state.dateRange;
}
if (typeof state.page === "number" && state.page > 0) {
page.value = state.page;
}
if (typeof state.pageSize === "number" && state.pageSize > 0) {
pageSize.value = state.pageSize;
}
if (typeof state.activeStat === "string") {
activeStat.value = state.activeStat;
}
} catch (_) {
//
}
};
const clearListState = () => {
try {
sessionStorage.removeItem(LIST_STATE_KEY);
} catch (_) {
/* noop */
}
};
const goDetail = (id) => router.push(`/hospital/orders/detail/${id}`);
const goEdit = (id) => router.push(`/hospital/orders/edit/${id}`);
const goQuick = () => router.push("/hospital/orders/quick");
const cancel = async (row) => {
try {
await ElMessageBox.confirm(`${$t('msg.confirmCancelOrder').replace('{orderId}', row.title)}`, $t('common.confirm'), {
await ElMessageBox.confirm(
$t('msg.confirmCancelOrder').replace('{orderId}', row.title),
$t('common.confirm'),
{
type: "warning",
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
});
},
);
await cancelHospitalOrder(row.id);
ElMessage.success($t('msg.cancelSuccess'));
loadList();
@ -136,7 +406,35 @@ const cancel = async (row) => {
}
};
onMounted(loadList);
// / /
onBeforeRouteLeave((to) => {
const path = to?.path || "";
if (
path.startsWith("/hospital/orders/detail") ||
path.startsWith("/hospital/orders/process") ||
path.startsWith("/hospital/orders/edit")
) {
saveListState();
}
});
//
const applyRouteQuery = () => {
const { status, priority, type, keyword, startDate, endDate } = route.query;
if (status) filters.status = String(status);
if (priority) filters.priority = String(priority);
if (type) filters.type = String(type);
if (keyword) filters.keyword = String(keyword);
if (startDate && endDate) dateRange.value = [String(startDate), String(endDate)];
};
onMounted(() => {
applyRouteQuery();
// /
restoreListState();
clearListState();
loadList();
});
</script>
<style scoped lang="scss">
@ -148,4 +446,77 @@ onMounted(loadList);
display: flex;
justify-content: flex-end;
}
//
.order-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.stat-card {
position: relative;
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;
}
.stat-label {
font-size: 13px;
color: #999;
margin-top: 4px;
}
</style>

@ -2,7 +2,7 @@
<div class="quick-submit">
<el-card shadow="never">
<template #header>
<span class="card-title">{{ $t('modal.submit') }}</span>
<span class="card-title">{{ isEdit ? '编辑工单' : $t('modal.submit') }}</span>
</template>
<el-form
@ -10,44 +10,139 @@
:model="form"
:rules="rules"
label-width="120px"
style="max-width: 720px"
>
<el-form-item :label="$t('label.title')" prop="title">
<el-input v-model="form.title" :placeholder="$t('placeholder.title')" maxlength="100" show-word-limit />
<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')" style="width: 240px">
<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="$t('label.priority')" prop="priority">
<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-item>
<el-form-item :label="$t('label.dept')">
<el-input v-model="form.department" :placeholder="$t('placeholder.dept')" />
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel" class="inline-item">
<el-select
v-model="form.feedbackchannel"
:placeholder="$t('placeholder.feedbackchannel')"
clearable
style="width: 200px"
>
<el-option label="微信群" value="微信群" />
<el-option label="微信" value="微信" />
<el-option label="电话" value="电话" />
<el-option label="现场" value="现场" />
<el-option label="钉钉群" value="钉钉群" />
<el-option label="钉钉" value="钉钉" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</div>
</el-form-item>
<el-form-item :label="$t('label.colorTag')">
<el-radio-group v-model="form.colorTag">
<el-radio-button label=""></el-radio-button>
<el-radio-button label="red"><span style="color:#F4664A"></span></el-radio-button>
<el-radio-button label="yellow"><span style="color:#F6BD16"></span></el-radio-button>
<el-radio-button label="blue"><span style="color:#5B8FF9"></span></el-radio-button>
</el-radio-group>
<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.description')" prop="description">
<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"
style="border-bottom: 1px solid #dcdfe6"
/>
<Editor
v-model="form.description"
type="textarea"
:rows="5"
:placeholder="$t('placeholder.description')"
:default-config="editorConfig"
mode="default"
style="height: 200px; overflow-y: auto"
@on-created="handleEditorCreated"
@custom-paste="handleEditorPasteImage"
/>
</div>
</el-form-item>
<el-form-item class="fujian" :label="$t('label.attachment')">
<el-upload
v-model:file-list="form.files"
:auto-upload="false"
:limit="1"
:on-exceed="handleAttachmentExceed"
:on-remove="handleAttachmentRemove"
:on-change="handleAttachmentChange"
>
<el-button type="primary" plain>{{ $t('btn.upload') }}</el-button>
<template #tip>
<div class="el-upload__tip">
{{ $t('label.attachmentTip') }}
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="submit">{{ $t('common.submit') }}</el-button>
@ -55,45 +150,417 @@
</el-form-item>
</el-form>
</el-card>
<!-- 提交工单后 +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>
</div>
</template>
<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import dayjs from "dayjs";
import {
nextTick,
onBeforeUnmount,
reactive,
ref,
shallowRef,
computed,
watch,
onMounted,
} from "vue";
import { useRouter,useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { useI18n } from "vue-i18n";
import { createHospitalOrder } from "@/service/modular/hospital";
import {
createHospitalOrder,
getHospitalOrderDetail,
updateHospitalOrder,
getPoints,
} from "@/service/modular/hospital";
// ( points )
const EXCHANGE_GOAL = 500;
const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const formRef = ref();
const submitting = ref(false);
// url :id
const isEdit = computed(() => !!route.params.id);
const editId = computed(() => route.params.id);
//
const getAttachmentFileName = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").pop() || "";
};
// filePath
const originalAttachments = ref([]);
//
const fillFormFromDetail = (detail) => {
if (!detail) return;
form.title = detail.title || "";
form.serviceType = detail.serviceType || "";
form.priority = detail.priority || "中";
form.feedbackchannel = detail.feedbackChannel || "";
form.department = detail.department || "";
form.colorTag = detail.colorTag || "#667eea";
form.description = detail.description || "";
form.submitter = detail.submitter || "";
form.createdat = detail.createdAt
? dayjs(detail.createdAt).format("YYYY-MM-DD HH:mm:ss")
: "";
// el-upload
const atts = detail.attachments || [];
originalAttachments.value = atts.map((a) => a.filePath).filter(Boolean);
if (atts.length) {
const first = atts[0];
const fileName = first.fileName || getAttachmentFileName(first.filePath);
if (fileName) {
form.files = [
{
uid: `attachment-${editId.value}-0`,
name: fileName,
status: "success",
},
];
return;
}
}
form.files = [];
};
const loadDetail = async (id) => {
try {
const res = await getHospitalOrderDetail(id);
fillFormFromDetail(res);
} catch (_) {
ElMessage.error("加载工单详情失败");
}
};
// id
watch(
() => route.params.id,
(id) => {
if (id) {
loadDetail(id);
}
},
);
const form = reactive({
title: "",
serviceType: "",
priority: "中",
feedbackchannel: "",
department: "",
colorTag: "",
colorTag: "#667eea",
description: "",
submitter: "",
createdat: "",
files: [],
});
const rules = {
title: [{ required: true, message: () => $t('msg.pleaseInputTitle'), trigger: "blur" }],
serviceType: [{ required: true, message: () => $t('msg.pleaseSelectType'), trigger: "change" }],
priority: [{ required: true, message: () => $t('msg.pleaseSelectPriority'), trigger: "change" }],
description: [{ required: true, message: () => $t('msg.pleaseInputDescription'), trigger: "blur" }],
feedbackchannel: [
{ required: true, message: () => $t('msg.pleaseSelectFeedbackChannel'), trigger: "change" },
],
department: [
{ required: true, whitespace: true, message: () => $t('msg.pleaseInputDept'), trigger: "blur" },
],
submitter: [
{ required: true, whitespace: true, message: () => $t('msg.pleaseInputName'), trigger: "blur" },
],
description: [
{
required: true,
validator: (_, value, callback) => {
const hasImage = /<img\s/i.test(value || "");
const text = (value || "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, "")
.trim();
if (!text && !hasImage) return callback(new Error($t('msg.pleaseInputDescription')));
callback();
},
trigger: "change",
},
],
};
//
const colorOptions = [
{ label: "蓝", value: "#667eea" },
{ label: "绿", value: "#52c41a" },
{ label: "青", value: "#13c2c2" },
{ label: "紫", value: "#722ed1" },
{ label: "橙", value: "#fa8c16" },
{ 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;
}
};
const MAX_FILE_SIZE = 50 * 1024 * 1024;
const MAX_PASTE_IMAGE_SIZE = 5 * 1024 * 1024;
const handleAttachmentChange = (file) => {
if (file?.size > MAX_FILE_SIZE) {
ElMessage.warning("文件不能超过 50MB");
form.files = form.files.filter(
(f) => f.uid !== file.uid || f.size <= MAX_FILE_SIZE,
);
}
};
const handleAttachmentExceed = () => {
ElMessage.warning("只能上传 1 个文件");
};
const handleAttachmentRemove = () => {
// el-upload v-model:file-list
};
const nowDatetime = () => dayjs().format("YYYY-MM-DD HH:mm:ss");
//
const editorRef = shallowRef();
const toolbarConfig = {
excludeKeys: [
"uploadImage",
"insertVideo",
"uploadVideo",
"group-video",
"emotion",
"group-emoji",
],
};
const collectPastedImages = (clipboardData) => {
const files = [];
const items = clipboardData?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file" && item.type?.startsWith("image/")) {
const file = item.getAsFile();
if (file) files.push(file);
}
}
}
if (files.length === 0 && clipboardData?.files) {
for (let i = 0; i < clipboardData.files.length; i++) {
const file = clipboardData.files[i];
if (file.type?.startsWith("image/")) files.push(file);
}
}
return files;
};
const handleEditorPasteImage = (editor, event) => {
if (event.__wpsImageHandled) return;
const imageFiles = collectPastedImages(event.clipboardData);
if (imageFiles.length === 0) return;
event.__wpsImageHandled = true;
event.preventDefault();
if (typeof event.stopPropagation === "function") event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function")
event.stopImmediatePropagation();
if (typeof editor.focus === "function") editor.focus();
const savedSelection = editor.selection;
imageFiles.forEach((file) => {
if (file.size > MAX_PASTE_IMAGE_SIZE) {
ElMessage.warning("粘贴的图片不能超过 5MB");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result;
if (!dataUrl) {
ElMessage.error("图片读取失败");
return;
}
try {
if (savedSelection) editor.select(savedSelection);
} catch (_) {
if (typeof editor.focus === "function") editor.focus();
}
if (typeof editor.focus === "function") editor.focus();
editor.dangerouslyInsertHtml(
`<img src="${dataUrl}" style="max-width:100%;height:auto;" />`,
);
if (typeof editor.updateView === "function") editor.updateView();
};
reader.onerror = () => {
ElMessage.error("图片处理失败");
};
reader.readAsDataURL(file);
});
return false;
};
const editorConfig = {
placeholder: $t('placeholder.description'),
};
let pasteHandlerRef = null;
const handleEditorCreated = (editor) => {
editorRef.value = editor;
nextTick(() => {
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
if (pasteHandlerRef) {
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
}
pasteHandlerRef = (event) => handleEditorPasteImage(editor, event);
editable.addEventListener("paste", pasteHandlerRef, { capture: true });
});
};
// ============== ==============
// +1
const rewardVisible = ref(false);
const rewardTotalPoints = ref(0);
const rewardProgress = ref(0); // (%)
const rewardDistance = ref(0); //
const playCoinAnimation = () => {
// : class minimal
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;
try {
const res = await getPoints();
const data = res?.data ?? res ?? {};
total = Number(data.totalPoints) || 0;
} catch (_) {
//
}
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;
router.replace("/hospital/orders");
};
const goMyAssets = () => {
rewardVisible.value = false;
router.replace("/hospital/points");
};
const submit = async () => {
form.department = (form.department || "").trim();
form.submitter = (form.submitter || "").trim();
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
await createHospitalOrder(form);
ElMessage.success($t('msg.submitSuccess'));
const formData = new FormData();
const { files, ...rest } = form;
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined && v !== null && v !== "") {
formData.append(k, v);
}
}
for (const f of files || []) {
if (f.raw) formData.append("files", f.raw, f.name);
}
if (isEdit.value) {
await updateHospitalOrder(editId.value, formData);
ElMessage.success("编辑成功");
router.replace("/hospital/orders");
} else {
await createHospitalOrder(formData);
ElMessage.success($t('msg.submitSuccess'));
// +1
showRewardDialog();
}
} finally {
submitting.value = false;
}
@ -101,11 +568,306 @@ const submit = async () => {
};
const onCancel = () => router.back();
onMounted(() => {
document.addEventListener("click", onDocClick);
if (isEdit.value) {
//
loadDetail(editId.value);
} else {
//
form.createdat = nowDatetime();
}
});
const removePasteListener = () => {
const editor = editorRef.value;
if (!editor || !pasteHandlerRef) return;
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
};
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
</script>
<style scoped lang="scss">
.quick-submit {
:deep(.el-card) {
max-width: 100%;
}
.el-form {
max-width: 100%;
}
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.rich-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
width: 100%;
overflow: hidden;
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
background: #fafafa;
}
// +
.title-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.title-row .el-input {
flex: 1;
}
// +
.inline-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 24px;
width: 100%;
}
.inline-row .inline-item {
margin-bottom: 0 !important;
}
.inline-row .inline-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
}
.color-tag-picker {
position: relative;
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;
}
.color-tag-preview:hover {
transform: scale(1.08);
}
.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;
}
.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;
}
.color-tag-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.color-tag-options {
display: flex;
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;
}
.color-tag-option:hover {
transform: scale(1.12);
}
.color-tag-option.active {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
//
:deep(.fujian .el-form-item__content > div) {
width: 100% !important;
}
// ============== ==============
: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);
}
</style>

@ -32,6 +32,7 @@ import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { changePassword } from "@/service/modular/hospital";
const { t } = useI18n();
const router = useRouter();
@ -55,14 +56,23 @@ const rules = {
};
const submit = async () => {
await formRef.value.validate((valid) => {
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
await changePassword({
oldPassword: form.oldPassword,
newPassword: form.newPassword,
});
ElMessage.success(t('password.success'));
setTimeout(() => {
router.replace('/login');
}, 1000);
} catch (err) {
console.log(err?.message || t('common.failed'));
} finally {
submitting.value = false;
ElMessage.success(t('password.success'));
router.back();
}, 600);
}
});
};

File diff suppressed because it is too large Load Diff

@ -3,9 +3,8 @@
<!-- 时间筛选 -->
<div class="filter-bar">
<el-radio-group v-model="period" size="small" @change="loadStats">
<el-radio-button label="day">{{ $t('common.today') }}</el-radio-button>
<el-radio-button label="week">{{ $t('common.thisWeek') }}</el-radio-button>
<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>
@ -25,7 +24,7 @@
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card" v-for="card in cards" :key="card.label">
<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>
@ -40,10 +39,24 @@
<el-card class="trend-card" shadow="never">
<template #header>
<div class="card-header">
<span>{{ $t('chart.title') }}</span>
<div class="legend">
<span class="legend-item"><i class="dot" style="background:#5B8FF9" />{{ $t('chart.submit') }}</span>
<span class="legend-item"><i class="dot" style="background:#5AD8A6" />{{ $t('chart.complete') }}</span>
<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>
</template>
@ -53,27 +66,31 @@
</template>
<script setup>
import dayjs from "dayjs";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Tickets, Check, Bell, Warning } from "@element-plus/icons-vue";
import VChart from "vue-echarts";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import { LineChart, BarChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
} from "echarts/components";
import { useI18n } from "vue-i18n";
import { getWorkbench } from "@/service/modular/hospital";
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent]);
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent]);
const { t: $t } = useI18n();
const period = ref("month");
const dateRange = ref([]);
const chartType = ref("line");
const stats = ref({
totalOrders: 0,
@ -83,8 +100,11 @@ const stats = ref({
trendData: [],
});
const router = useRouter();
const cards = computed(() => [
{
key: "total",
label: $t('stat.total'),
value: stats.value.totalOrders,
icon: Tickets,
@ -92,6 +112,7 @@ const cards = computed(() => [
bg: "rgba(91,143,249,0.12)",
},
{
key: "completed",
label: $t('stat.completed'),
value: stats.value.completedOrders,
icon: Check,
@ -99,6 +120,7 @@ const cards = computed(() => [
bg: "rgba(90,216,166,0.12)",
},
{
key: "pending",
label: $t('stat.pending'),
value: stats.value.pendingOrders,
icon: Bell,
@ -106,6 +128,7 @@ const cards = computed(() => [
bg: "rgba(246,189,22,0.12)",
},
{
key: "highPriority",
label: $t('stat.highPriority'),
value: stats.value.highPriorityOrders,
icon: Warning,
@ -114,38 +137,97 @@ const cards = computed(() => [
},
]);
// startDate/endDate
const resolveDateRange = () => {
if (period.value === "custom" && dateRange.value?.length === 2) {
return { startDate: dateRange.value[0], endDate: dateRange.value[1] };
}
const now = dayjs();
if (period.value === "month") {
return { startDate: now.startOf("month").format("YYYY-MM-DD"), endDate: now.endOf("month").format("YYYY-MM-DD") };
}
if (period.value === "quarter") {
return { startDate: now.startOf("quarter").format("YYYY-MM-DD"), endDate: now.endOf("quarter").format("YYYY-MM-DD") };
}
if (period.value === "year") {
return { startDate: now.startOf("year").format("YYYY-MM-DD"), endDate: now.endOf("year").format("YYYY-MM-DD") };
}
return {};
};
//
const goToOrders = (card) => {
const query = { ...resolveDateRange() };
if (card.key === "completed") query.status = "已完成";
else if (card.key === "pending") query.status = "待处理";
else if (card.key === "highPriority") query.priority = "高";
router.push({ path: "/hospital/orders", query });
};
const chartOption = computed(() => {
const data = stats.value.trendData || [];
const dates = data.map((d) => d.date);
const submit = data.map((d) => d.submitCount);
const complete = data.map((d) => d.completeCount);
const isLine = chartType.value === "line";
const showSlider = dates.length > 20;
return {
grid: { left: 30, right: 20, top: 30, bottom: 30 },
grid: { left: 30, right: 70, top: 30, bottom: showSlider ? 60 : 30 },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: dates, boundaryGap: false, axisLine: { lineStyle: { color: "#e4e7ed" } } },
yAxis: { type: "value", axisLine: { show: false }, splitLine: { lineStyle: { color: "#f0f2f5" } } },
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,
axisLine: { lineStyle: { color: "#e4e7ed" } },
},
yAxis: {
type: "value",
min: 0,
minInterval: 1,
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 }]
: []),
],
series: [
{
name: $t('chart.submit'),
type: "line",
smooth: true,
type: chartType.value,
barWidth: 14,
barGap: "20%",
smooth: isLine,
symbol: "circle",
symbolSize: 8,
data: submit,
lineStyle: { color: "#5B8FF9", width: 2 },
itemStyle: { color: "#5B8FF9" },
areaStyle: { color: "rgba(91,143,249,0.08)" },
itemStyle: { color: "#5B8FF9", borderRadius: isLine ? 0 : [3, 3, 0, 0] },
areaStyle: isLine ? { color: "rgba(91,143,249,0.08)" } : undefined,
},
{
name: $t('chart.complete'),
type: "line",
smooth: true,
type: chartType.value,
barWidth: 14,
barGap: "20%",
smooth: isLine,
symbol: "circle",
symbolSize: 8,
data: complete,
lineStyle: { color: "#5AD8A6", width: 2 },
itemStyle: { color: "#5AD8A6" },
areaStyle: { color: "rgba(90,216,166,0.08)" },
itemStyle: { color: "#5AD8A6", borderRadius: isLine ? 0 : [3, 3, 0, 0] },
areaStyle: isLine ? { color: "rgba(90,216,166,0.08)" } : undefined,
},
],
};
@ -193,6 +275,13 @@ onMounted(loadStats);
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
@ -226,19 +315,51 @@ onMounted(loadStats);
display: flex;
align-items: center;
justify-content: space-between;
.legend {
display: flex;
gap: 16px;
font-size: 13px;
color: #606266;
.legend-item {
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.chart-switch {
display: inline-flex;
background: #f5f7fa;
border-radius: 4px;
padding: 2px;
.switch-item {
display: inline-flex;
align-items: center;
gap: 6px;
.dot {
gap: 4px;
padding: 4px 10px;
font-size: 12px;
color: #606266;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
user-select: none;
.switch-icon {
width: 10px;
height: 10px;
border-radius: 50%;
background: #5AD8A6;
border-radius: 1px;
&.line {
background: #5B8FF9;
}
}
&:hover {
color: #409eff;
}
&.active {
background: #409eff;
color: #fff;
.switch-icon {
background: #fff;
&.line {
background: #fff;
}
}
&:hover {
color: #fff;
}
}
}
}

Loading…
Cancel
Save