You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

578 lines
17 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="admin-layout">
<header class="admin-header">
<!-- 管理端显示 Logo / 医院端显示医院名 -->
<div class="logo-bar">
<span v-if="!isHospitalMode" class="logo-text admin"> 康策CSM系统管理平台<span class="version">v1.0</span> </span>
<span v-else class="logo-text hospital">{{ userStore.userInfo?.hospitalName || "医院端" }}</span>
</div>
<div class="header-middle">
<!-- 顶部水平菜单:管理端 / 医院端共用,按当前顶层路由动态生成 -->
<el-menu class="header-menu" mode="horizontal" :default-active="$route.path" :ellipsis="false" router>
<el-menu-item v-for="item in sideRoutes" :key="item.path" :index="item.path">
<SvgIcon :name="item.iconName" class="nav-icon" />
<template #title>
<span>{{ item.title }}</span>
</template>
</el-menu-item>
</el-menu>
<!-- 医院端:顶部菜单旁的快速提交按钮 -->
<div v-if="isHospitalMode" class="header-quick-submit">
<el-button type="primary" class="quick-submit-btn" @click="orderDialogStore.open()">
<img src="@/assets/image/hospital/quick.svg" class="quick-icon" alt="" />
<span>快速提交</span>
</el-button>
</div>
</div>
<div class="header-right">
<el-dropdown trigger="hover" @command="handleCommand">
<div class="user-trigger">
<!-- <SvgIcon name="bell-filled" :size="20" color="#fff" /> -->
<span class="username">{{
userStore.userInfo?.name ||
userStore.userInfo?.userName ||
"管理员"
}}</span>
<el-avatar :size="40" :src="avatarSrc"></el-avatar>
<!-- <el-icon>
<ArrowDown />
</el-icon> -->
</div>
<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>
</el-dropdown>
</div>
</header>
<main class="admin-content">
<slot />
</main>
<!-- 更换主题弹窗 -->
<el-dialog v-model="themeDialogVisible" class="theme-dialog" :close-on-click-modal="false" align-center
destroy-on-close>
<div class="theme-body">
<div class="theme-header">
<span class="theme-title">更换主题</span>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="themeDialogVisible = false">
</div>
<div class="theme-grid">
<div v-for="t in presetThemes" :key="t.value" class="theme-card"
:class="{ active: themeStore.themeValue === t.value }" :style="{
'--brand-color': t.borderColor
}" @click="handlePresetTheme(t.value)">
<div class="theme-preview" :style="{ background: t.preview, boxShadow: t.boxShadow }"></div>
<div class="theme-name">{{ t.name }}</div>
<div class="theme-desc">{{ t.desc }}</div>
</div>
</div>
</div>
</el-dialog>
<!-- 全局工单表单弹窗(医院端快速提交 / 列表编辑统一使用) -->
<OrderFormDialog v-model="orderDialogStore.visible" :order-id="orderDialogStore.orderId"
@success="onOrderDialogSuccess" />
<!-- 修改密码弹窗(管理端 / 医院端共用,根据 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"
:has-drawn="signInDialogStore.summary.isDraw" @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, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
import SvgIcon from "@/components/SvgIcon/index.vue";
import { useUserStore } from "@/stores/api/user";
import { useThemeStore } from "@/stores/theme";
import { PRESET_THEMES } from "@/stores/theme/buildThemeVars.js";
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 avatarMale from "@/assets/image/avatar-male.svg";
import avatarFemale from "@/assets/image/avatar-female.svg";
const route = useRoute();
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"));
// 头部快速提交 / 列表编辑成功后通知当前路由对应的列表页刷新
const onOrderDialogSuccess = () => {
// 触发全局事件,由 list.vue / detail.vue 监听
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);
// 修改密码弹窗显隐
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();
}
};
// 预设主题配置:颜色 / 渐变端点 / 阴影 / 名称 / 描述均来自 buildThemeVars.PRESET_THEMES
// preview 按当前 brandColor + gradientTo 组装,保持 to bottom卡片圆球视觉
const presetThemes = computed(() =>
Object.entries(PRESET_THEMES).map(
([value, { borderColor, gradientTo, boxShadow, name, desc }]) => ({
value,
borderColor,
preview: `linear-gradient(to bottom, ${borderColor} 0%, ${gradientTo} 100%)`,
boxShadow,
name,
desc,
}),
),
);
// 选择预设主题
const handlePresetTheme = (value) => {
themeStore.setTheme(value);
};
// 顶部水平菜单:根据当前顶层路由(/admin 或 /hospital动态生成
// 图标 name 直接使用 meta.icon(去掉 admin-/hospital- 前缀后即为 SvgIcon 的 name)
const sideRoutes = computed(() => {
const groupPath = isHospitalMode.value ? "/hospital" : "/admin";
const groupRoute = route.matched.find((r) => r.path === groupPath);
if (!groupRoute || !groupRoute.children) return [];
return groupRoute.children
.filter((c) => c.meta?.title && !c.meta?.hidden && !c.path.includes(":"))
.map((c) => ({
path: `${groupPath}/${c.path}`,
title: c.meta.title,
iconName: (c.meta?.icon || "").replace(/^(admin|hospital)-/, ""),
}));
});
const currentTitle = computed(() => {
const last = [...route.matched].reverse().find((r) => r.meta?.title);
return last?.meta?.title || "管理端";
});
// 头像sex === 1 用男,否则用女
const avatarSrc = computed(() => {
return userStore.userInfo?.gender === '女' ? avatarFemale : avatarMale;
});
const handleCommand = async (cmd) => {
if (cmd === "password") {
passwordDialogVisible.value = true;
return;
}
if (cmd === "theme") {
themeDialogVisible.value = true;
return;
}
if (cmd === "logout") {
try {
await ElMessageBox.confirm("确定要退出登录吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
userStore.clear();
router.push("/login");
} catch (_) {
/* canceled */
}
}
};
</script>
<style scoped lang="scss">
.admin-layout {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
background-color: #f9faff;
padding: 32px;
gap: 23px;
background-image: url("@/assets/image/layout-bg.svg");
background-repeat: no-repeat;
background-size: 100% auto;
background-position: right top;
.admin-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0;
height: 96px;
border-radius: 16px 16px 16px 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: linear-gradient(to right, #f9faff 0, #f9faff 75%, #a6cffe 100%);
background: linear-gradient(to right, #f9faff 0, #f9faff 75%, var(--el-color-primary-light-8) 100%);
.logo-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
height: 100%;
.logo-text {
background: var(--linear-right);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 22px;
.version {
font-size: 14px;
}
}
}
.header-middle {
display: flex;
align-items: center;
height: 100%;
min-width: 0;
}
.header-quick-submit {
margin-left: 14px;
}
.quick-submit-btn {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 16px;
border-radius: 18px;
font-weight: 500;
// background: linear-gradient(90deg, #3289FB 0%, #00B2FF 100%);
background: var(--linear-right);
border-radius: 28px 28px 28px 28px !important;
border: 1px solid rgba(255, 255, 255, 0.15);
width: 155px;
height: 43px;
font-weight: 400;
font-size: 14px;
color: #FFFFFF;
.quick-icon {
width: 16px;
height: 16px;
margin-right: 4px;
}
}
.header-right {
display: flex;
align-items: center;
height: 100%;
padding-right: 30px;
.user-trigger {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
color: #000000;
}
}
.header-menu {
height: 100%;
flex-shrink: 0;
flex-grow: 0;
min-width: 0;
background: transparent !important;
border-bottom: none !important;
height: 100%;
display: flex;
align-items: center !important;
// 顶部一级菜单
:deep(.el-menu-item) {
font-weight: 400;
font-size: 16px;
color: #1d2129 !important;
height: 60px;
line-height: 60px;
border-bottom: none !important;
padding: 0 14px !important;
display: flex;
align-items: center;
&:hover {
// background-color: rgba(50, 137, 251, 0.08) !important;
color: var(--brand-color) !important;
}
}
// 一级激活
:deep(.el-menu-item.is-active) {
font-weight: 400;
font-size: 16px;
color: var(--brand-color) !important;
background-color: transparent !important;
border-bottom: none !important;
&::after {
display: none !important;
}
}
}
}
.admin-content {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
}
}
.nav-icon {
width: 18px;
height: 18px;
margin-right: 6px;
vertical-align: middle;
object-fit: contain;
flex-shrink: 0;
color: currentColor;
}
// 医院端 Element Plus 图标尺寸
.el-icon.nav-icon {
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
color: inherit;
:deep(svg) {
width: 18px;
height: 18px;
}
}
</style>
<style lang="scss">
.admin-layout {
.el-menu-item:not(.is-disabled):focus,
.el-menu--horizontal .el-menu-item:not(.is-disabled):hover {
background-color: transparent !important;
}
}
.theme-dialog {
width: max-content;
border-radius: 24px 24px 24px 24px;
.theme-body {
padding: 24px 24px 33px;
border-radius: 24px 24px 24px 24px;
background-color: #fff;
width: max-content;
overflow: hidden;
.theme-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 20px;
color: #1D2129;
margin-bottom: 16px;
.theme-title {
font-weight: 600;
font-size: 20px;
color: #1D2129;
}
.close-btn {
width: 30px;
height: 30px;
cursor: pointer;
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
.theme-card {
width: 126px;
height: 162px;
border-radius: 12px 12px 12px 12px;
border: 1px solid #E0E0E0;
padding: 15px;
cursor: pointer;
transition: all 0.25s ease;
text-align: center;
background: #fff;
white-space: nowrap;
display: flex;
flex-direction: column;
align-items: center;
&:hover {
border-color: var(--brand-color);
}
&.active {
border-color: var(--brand-color);
}
}
.theme-preview {
width: 64px;
height: 64px;
border: 4px solid #FFFFFF;
border-radius: 50%;
margin-bottom: 12px;
}
.theme-name {
font-weight: 600;
font-size: 14px;
color: #1D2129;
margin-bottom: 4px;
}
.theme-desc {
font-weight: 400;
font-size: 12px;
color: #666666;
white-space: pre-line;
}
}
}
}
</style>