{{ t.name }}
@@ -117,6 +112,7 @@ 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";
@@ -129,28 +125,6 @@ import AssetsDialog from "@/views/hospital/AssetsDialog.vue";
import { getPoints, getPointsDetails } from "@/service/modular/hospital";
import dayjs from "dayjs";
-// 顶部菜单图标(直接以静态资源方式 import,绕开 SvgIcon 的 fill 干扰)
-// 管理端
-import fuwuGd from "@/assets/image/router/fuwu-gd.svg";
-import fuwuGdActive from "@/assets/image/router/fuwu-gd-active.svg";
-import fuwuBg from "@/assets/image/router/fuwu-bg.svg";
-import fuwuBgActive from "@/assets/image/router/fuwu-bg-active.svg";
-import yiyuanXx from "@/assets/image/router/yiyuan-xx.svg";
-import yiyuanXxActive from "@/assets/image/router/yiyuan-xx-active.svg";
-import kehuFw from "@/assets/image/router/kehu-fw.svg";
-import kehuFwActive from "@/assets/image/router/kehu-fw-active.svg";
-import kehuJljf from "@/assets/image/router/kehu-jljf.svg";
-import kehuJljfActive from "@/assets/image/router/kehu-jljf-active.svg";
-import zhanghaoGl from "@/assets/image/router/zhanghao-gl.svg";
-import zhanghaoGlActive from "@/assets/image/router/zhanghao-gl-active.svg";
-// 医院端
-import yyGzt from "@/assets/image/router/yy-gzt.svg";
-import yyGztActive from "@/assets/image/router/yy-gzt-active.svg";
-import yyGd from "@/assets/image/router/yy-gd.svg";
-import yyGdActive from "@/assets/image/router/yy-gd-active.svg";
-import yyJf from "@/assets/image/router/yy-jf.svg";
-import yyJfActive from "@/assets/image/router/yy-jf-active.svg";
-
// 头像(按性别)
import avatarMale from "@/assets/image/avatar-male.svg";
import avatarFemale from "@/assets/image/avatar-female.svg";
@@ -252,89 +226,43 @@ const openAssetsDialogWithFreshData = async () => {
}
};
-// 预设主题配置(与现有 scss 主题保持一致)
-const presetThemes = [
- {
- borderColor: "#3289FB",
- boxShadow: "0 4px 12px 0 rgba(50, 137, 251, 0.5)",
- value: "blue",
- name: "穹宇蓝",
- desc: "深邃星空\n科技感十足",
- preview: "linear-gradient(to bottom, #3289FB 0%, #77B2FF 100%)",
- },
- {
- borderColor: "#6155F5",
- boxShadow: "0 4px 12px 0 rgba(97, 85, 245, 0.5)",
- value: "purple",
- name: "星轨紫",
- desc: "神秘优雅\n梦幻氛围",
- preview: "linear-gradient(to bottom, #6155F5 0%, #9B93FF 100%)",
- },
- {
- borderColor: "#DE6262",
- boxShadow: "0 4px 12px 0 rgba(222, 98, 98, 0.5)",
- value: "red",
- name: "流金粉",
- desc: "晨雾染金\n自带光芒",
- preview: "linear-gradient(to bottom, #DE6262 0%, #FF988B 100%)",
- },
-];
+// 预设主题配置:颜色 / 渐变端点 / 阴影 / 名称 / 描述均来自 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);
};
-// 图标查找表:route meta.icon -> { normal, active }
-// 管理端 SVG
-const ICON_MAP = {
- "admin-router-fuwu-gd": { normal: fuwuGd, active: fuwuGdActive },
- "admin-router-fuwu-bg": { normal: fuwuBg, active: fuwuBgActive },
- "admin-router-yiyuan-xx": { normal: yiyuanXx, active: yiyuanXxActive },
- "admin-router-kehu-fw": { normal: kehuFw, active: kehuFwActive },
- "admin-router-kehu-jljf": { normal: kehuJljf, active: kehuJljfActive },
- "admin-router-zhanghao-gl": { normal: zhanghaoGl, active: zhanghaoGlActive },
-};
-
-// 医院端 SVG
-const HOSPITAL_SVG_ICON_MAP = {
- "hospital-router-yy-gzt": { normal: yyGzt, active: yyGztActive },
- "hospital-router-yy-gd": { normal: yyGd, active: yyGdActive },
- "hospital-router-yy-jf": { normal: yyJf, active: yyJfActive },
-};
-// 当前 hover 的菜单项 path
-const hoveredPath = ref("");
-// 判断菜单项是否处于激活态
-const isActiveMenu = (path) => {
- if (!path || !route.path) return false;
- return route.path === path;
-};
-// hover 或激活时都用 active 图标
-const isMenuHoveredOrActive = (path) => {
- return isActiveMenu(path) || hoveredPath.value === path;
-};
// 顶部水平菜单:根据当前顶层路由(/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) => {
- // 管理端 / 医院端都按 SVG(normal + active)查表
- const iconMap = isHospitalMode.value ? HOSPITAL_SVG_ICON_MAP : ICON_MAP;
- const icon = iconMap[c.meta?.icon];
- return {
- path: `${groupPath}/${c.path}`,
- title: c.meta.title,
- iconUrl: icon?.normal || "",
- iconUrlActive: icon?.active || "",
- };
- });
+ .map((c) => ({
+ path: `${groupPath}/${c.path}`,
+ title: c.meta.title,
+ iconName: (c.meta?.icon || "").replace(/^(admin|hospital)-/, ""),
+ }));
});
const currentTitle = computed(() => {
@@ -359,7 +287,7 @@ const handleCommand = async (cmd) => {
if (cmd === "logout") {
try {
await ElMessageBox.confirm("确定要退出登录吗?", "提示", {
- confirmButtonText: "退出",
+ confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
@@ -379,16 +307,16 @@ const handleCommand = async (cmd) => {
width: 100vw;
height: 100vh;
background-color: #f9faff;
- padding: 32px;
+ padding: 32px 0;
gap: 23px;
background-image: url("@/assets/image/layout-bg.svg");
background-repeat: no-repeat;
background-size: 100% auto;
background-position: right top;
+ overflow-y: scroll;
.admin-header {
flex-shrink: 0;
- height: 96px;
display: flex;
align-items: center;
justify-content: space-between;
@@ -396,7 +324,9 @@ const handleCommand = async (cmd) => {
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%, #a6cffe 100%);
+ background: linear-gradient(to right, #f9faff 0, #f9faff 75%, var(--el-color-primary-light-8) 100%);
+ margin: 0px 32px 0;
.logo-bar {
display: flex;
@@ -409,7 +339,7 @@ const handleCommand = async (cmd) => {
height: 100%;
.logo-text {
- background: linear-gradient(to right, #3fc5fd, #3b90fa);
+ background: var(--linear-right);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 22px;
@@ -439,8 +369,9 @@ const handleCommand = async (cmd) => {
padding: 0 16px;
border-radius: 18px;
font-weight: 500;
- background: linear-gradient(90deg, #3289FB 0%, #00B2FF 100%);
- border-radius: 28px 28px 28px 28px;
+ // 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;
@@ -498,7 +429,7 @@ const handleCommand = async (cmd) => {
&:hover {
// background-color: rgba(50, 137, 251, 0.08) !important;
- color: #3289fb !important;
+ color: var(--brand-color) !important;
}
}
@@ -506,7 +437,7 @@ const handleCommand = async (cmd) => {
:deep(.el-menu-item.is-active) {
font-weight: 400;
font-size: 16px;
- color: #3289fb !important;
+ color: var(--brand-color) !important;
background-color: transparent !important;
border-bottom: none !important;
@@ -521,6 +452,18 @@ const handleCommand = async (cmd) => {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
+ padding: 0 32px 0;
+
+ // /* 关键:隐藏原生滚动条,视觉上交给父容器代理 */
+ scrollbar-width: none;
+ /* Firefox */
+ -ms-overflow-style: none;
+ /* IE 10+ */
+
+ &::-webkit-scrollbar {
+ display: none;
+ /* Chrome, Safari, Edge */
+ }
}
}
@@ -531,6 +474,7 @@ const handleCommand = async (cmd) => {
vertical-align: middle;
object-fit: contain;
flex-shrink: 0;
+ color: currentColor;
}
// 医院端 Element Plus 图标尺寸
@@ -612,11 +556,11 @@ const handleCommand = async (cmd) => {
align-items: center;
&:hover {
- border-color: var(--border-color);
+ border-color: var(--brand-color);
}
&.active {
- border-color: var(--border-color);
+ border-color: var(--brand-color);
}
}
diff --git a/src/directives/scrollProxy.js b/src/directives/scrollProxy.js
new file mode 100644
index 0000000..c0c3bcd
--- /dev/null
+++ b/src/directives/scrollProxy.js
@@ -0,0 +1,202 @@
+/**
+ * v-scroll-proxy
+ *
+ * 唯一职责: 把子元素 (.child-scroll) 的原生滚动条样式同步绘制到父元素上
+ *
+ * 公式 (重要):
+ * ratio = ch / sh (子可视 / 子内容总高, 表示 "能看到多少比例的内容")
+ * thumbHeight = eh × ratio (父可视 × ratio, 但限制在 [20, eh])
+ * maxThumbTop = eh - thumbHeight
+ * ratioTop = scrollTop / (sh - ch)
+ * translateY = ratioTop × maxThumbTop
+ *
+ * 不做的事:
+ * - 不拦截 wheel / touch 事件
+ * - 不修改 scrollTop
+ * - 不修改 child 的任何样式
+ *
+ * 只做的事:
+ * - 在父元素右上角绘制一个自定义滚动条
+ * - 监听所有可能改变内容高度的事件, 动态更新
+ */
+export const vScrollProxy = {
+ mounted(el) {
+ const child = el.querySelector('.child-scroll');
+ if (!child) return;
+
+ // ---------- 滚动条 DOM ----------
+ const scrollbar = document.createElement('div');
+ const thumb = document.createElement('div');
+ scrollbar.className = '__v-scroll-proxy-scrollbar';
+ thumb.className = '__v-scroll-proxy-thumb';
+ scrollbar.appendChild(thumb);
+
+ scrollbar.style.cssText =
+ 'position:absolute;top:0;right:0;width:8px;height:100%;' +
+ 'background:transparent;border-radius:4px;z-index:9999;' +
+ 'opacity:0;transition:opacity .25s ease;pointer-events:none;' +
+ 'overflow:hidden;';
+
+ thumb.style.cssText =
+ 'position:absolute;top:0;right:1px;width:6px;' +
+ 'background:rgba(0,0,0,.35);border-radius:3px;' +
+ 'will-change:transform;';
+
+ if (getComputedStyle(el).position === 'static') {
+ el.style.position = 'relative';
+ }
+ el.appendChild(scrollbar);
+
+ // ---------- 显示/隐藏 ----------
+ let hideTimer = 0;
+ const showScrollbar = () => {
+ scrollbar.style.opacity = '1';
+ clearTimeout(hideTimer);
+ hideTimer = window.setTimeout(() => {
+ scrollbar.style.opacity = '0';
+ }, 900);
+ };
+
+ // ---------- 布局缓存 ----------
+ let scrollable = 0;
+ let thumbHeight = 20;
+ let maxThumbTop = 0;
+
+ /**
+ * 重新计算布局 (核心)
+ *
+ * 比例 = 子可视 / 子内容总高 = ch / sh
+ * thumb 高度 = 父可视 × 比例 = eh × (ch / sh)
+ */
+ const refreshLayout = () => {
+ const sh = child.scrollHeight;
+ const ch = child.clientHeight;
+ const eh = el.clientHeight;
+ scrollable = Math.max(sh - ch, 0);
+
+ let ratio;
+ if (sh > 0 && ch > 0) {
+ ratio = ch / sh;
+ } else {
+ ratio = 1;
+ }
+
+ let h = eh * ratio;
+ // 限制: [20px, eh]
+ thumbHeight = Math.min(Math.max(h, 20), eh);
+ maxThumbTop = Math.max(eh - thumbHeight, 0);
+ };
+
+ // ---------- 更新 thumb 位置 ----------
+ const updateThumb = () => {
+ if (scrollable <= 0) {
+ scrollbar.style.display = 'none';
+ return;
+ }
+ scrollbar.style.display = 'block';
+
+ if (thumb._h !== thumbHeight) {
+ thumb.style.height = thumbHeight + 'px';
+ thumb._h = thumbHeight;
+ }
+
+ const ratio = child.scrollTop / scrollable;
+ const ty = (ratio * maxThumbTop).toFixed(2);
+ thumb.style.transform = `translateY(${ty}px)`;
+ };
+
+ const fullUpdate = () => {
+ refreshLayout();
+ updateThumb();
+ };
+
+ // ---------- RAF 调度 ----------
+ let layoutRafId = 0;
+ let scrollRafId = 0;
+
+ const scheduleLayout = () => {
+ if (layoutRafId) return;
+ layoutRafId = requestAnimationFrame(() => {
+ layoutRafId = 0;
+ fullUpdate();
+ });
+ };
+
+ // scroll 事件: 既要更新 thumb 位置, 也要重算 thumb 高度
+ // (滚动过程中内容可能动态变化, 例如虚拟滚动到底加载)
+ const onChildScroll = () => {
+ if (scrollRafId) return;
+ scrollRafId = requestAnimationFrame(() => {
+ scrollRafId = 0;
+ fullUpdate();
+ });
+ showScrollbar();
+ };
+ child.addEventListener('scroll', onChildScroll, { passive: true });
+
+ // ---------- ResizeObserver: 监听尺寸变化 ----------
+ const ro = new ResizeObserver(() => {
+ scheduleLayout();
+ });
+ ro.observe(el);
+ ro.observe(child);
+ // 也观察子元素 (列表项图片加载完会撑高)
+ const observeKids = () => {
+ const kids = child.children;
+ for (let i = 0; i < kids.length; i++) {
+ ro.observe(kids[i]);
+ }
+ };
+ observeKids();
+
+ // ---------- MutationObserver: 监听 DOM 变化 ----------
+ const mo = new MutationObserver(() => {
+ scheduleLayout();
+ // 重新挂载观察 (新加的列表项)
+ observeKids();
+ });
+ mo.observe(child, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ });
+
+ // ---------- 图片加载: 也是导致高度变化的常见原因 ----------
+ const onImgLoad = () => scheduleLayout();
+ child.addEventListener('load', onImgLoad, true);
+
+ // ---------- 初始化: 延迟两帧 + 多重兜底 ----------
+ // 第一帧: DOM 已挂载
+ // 第二帧: Vue 子组件 mount 完
+ // 100ms: 网络请求回来的内容大致渲染完
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ fullUpdate();
+ });
+ });
+ setTimeout(() => fullUpdate(), 100);
+ setTimeout(() => fullUpdate(), 500);
+ setTimeout(() => fullUpdate(), 1500);
+
+ // ---------- 卸载清理 ----------
+ el.__scrollProxyCleanup__ = () => {
+ if (scrollRafId) cancelAnimationFrame(scrollRafId);
+ if (layoutRafId) cancelAnimationFrame(layoutRafId);
+ clearTimeout(hideTimer);
+ ro.disconnect();
+ mo.disconnect();
+ child.removeEventListener('scroll', onChildScroll);
+ child.removeEventListener('load', onImgLoad, true);
+ scrollbar.remove();
+ };
+ },
+
+ unmounted(el) {
+ if (typeof el.__scrollProxyCleanup__ === 'function') {
+ el.__scrollProxyCleanup__();
+ delete el.__scrollProxyCleanup__;
+ }
+ },
+};
+
+export default vScrollProxy;
\ No newline at end of file
diff --git a/src/main.js b/src/main.js
index e136a5c..ffdf078 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,7 +10,7 @@ import i18n from "./i18n/index.js";
import "@/assets/style/reset.css";
import "@/assets/style/index.scss";
import "@/assets/style/common.scss";
-
+import { vScrollProxy } from '@/directives/scrollProxy'
// SVG sprite 虚拟模块(必须引入才能注入 symbol)
import "virtual:svg-icons-register";
@@ -20,7 +20,7 @@ import SvgIcon from "@/components/SvgIcon/index.vue";
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
const app = createApp(App);
-
+app.directive('scrollProxy', vScrollProxy)
// 注册全局 SVG 图标组件
app.component("SvgIcon", SvgIcon);
@@ -30,9 +30,4 @@ app.use(router);
app.use(ElementPlus);
app.mount("#app");
-// 初始化主题
-import { useThemeStore } from "./stores/theme";
-const themeStore = useThemeStore();
-
-// 应用保存的主题到 html
-themeStore.applyTheme(themeStore.themeValue);
+// 主题在 stores/theme setup 内已自动初始化,无须在 main.js 手动调用
diff --git a/src/stores/theme/buildCustomThemeVars.js b/src/stores/theme/buildCustomThemeVars.js
deleted file mode 100644
index 4f07350..0000000
--- a/src/stores/theme/buildCustomThemeVars.js
+++ /dev/null
@@ -1,147 +0,0 @@
-const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
-
-const hueToRgb = (p, q, t) => {
- let temp = t;
- if (temp < 0) temp += 1;
- if (temp > 1) temp -= 1;
- if (temp < 1 / 6) return p + (q - p) * 6 * temp;
- if (temp < 1 / 2) return q;
- if (temp < 2 / 3) return p + (q - p) * (2 / 3 - temp) * 6;
- return p;
-};
-
-const hslToRgb = (h, s, l) => {
- const hue = (((h % 360) + 360) % 360) / 360;
- const saturation = clamp(s, 0, 100) / 100;
- const lightness = clamp(l, 0, 100) / 100;
-
- if (saturation === 0) {
- const gray = Math.round(lightness * 255);
- return { r: gray, g: gray, b: gray };
- }
-
- const q =
- lightness < 0.5
- ? lightness * (1 + saturation)
- : lightness + saturation - lightness * saturation;
- const p = 2 * lightness - q;
-
- return {
- r: Math.round(hueToRgb(p, q, hue + 1 / 3) * 255),
- g: Math.round(hueToRgb(p, q, hue) * 255),
- b: Math.round(hueToRgb(p, q, hue - 1 / 3) * 255),
- };
-};
-
-const parseColor = (color) => {
- const value = (color || "").trim();
-
- if (value.startsWith("#")) {
- const normalized = value.replace("#", "");
-
- if (normalized.length === 3 || normalized.length === 4) {
- const full = normalized
- .split("")
- .map((item) => item + item)
- .join("");
- const hasAlpha = normalized.length === 4;
- return {
- r: Number.parseInt(full.slice(0, 2), 16),
- g: Number.parseInt(full.slice(2, 4), 16),
- b: Number.parseInt(full.slice(4, 6), 16),
- a: hasAlpha ? Number.parseInt(full.slice(6, 8), 16) / 255 : 1,
- };
- }
-
- if (normalized.length === 6 || normalized.length === 8) {
- const hasAlpha = normalized.length === 8;
- return {
- r: Number.parseInt(normalized.slice(0, 2), 16),
- g: Number.parseInt(normalized.slice(2, 4), 16),
- b: Number.parseInt(normalized.slice(4, 6), 16),
- a: hasAlpha ? Number.parseInt(normalized.slice(6, 8), 16) / 255 : 1,
- };
- }
- }
-
- const rgbaMatch = value.match(/rgba?\(([^)]+)\)/i);
- if (rgbaMatch) {
- const [r = 0, g = 0, b = 0, a = 1] = rgbaMatch[1]
- .split(",")
- .map((item) => item.trim());
- return {
- r: clamp(Number.parseFloat(r), 0, 255),
- g: clamp(Number.parseFloat(g), 0, 255),
- b: clamp(Number.parseFloat(b), 0, 255),
- a: clamp(Number.parseFloat(a), 0, 1),
- };
- }
-
- const hslaMatch = value.match(/hsla?\(([^)]+)\)/i);
- if (hslaMatch) {
- const [h = 0, s = 0, l = 0, a = 1] = hslaMatch[1]
- .split(",")
- .map((item) => item.trim().replace("%", ""));
- const rgb = hslToRgb(
- Number.parseFloat(h),
- Number.parseFloat(s),
- Number.parseFloat(l),
- );
- return {
- ...rgb,
- a: clamp(Number.parseFloat(a), 0, 1),
- };
- }
-
- return { r: 23, g: 100, b: 255, a: 1 };
-};
-
-const colorToCss = ({ r, g, b, a = 1 }) => {
- const alpha = clamp(a, 0, 1);
- if (alpha < 1) {
- return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${Number(alpha.toFixed(3))})`;
- }
-
- const toHex = (value) =>
- clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0");
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
-};
-
-const mixColor = (color, target, amount) => {
- const sourceRgb = parseColor(color);
- const targetRgb = parseColor(target);
- return colorToCss({
- r: sourceRgb.r + (targetRgb.r - sourceRgb.r) * amount,
- g: sourceRgb.g + (targetRgb.g - sourceRgb.g) * amount,
- b: sourceRgb.b + (targetRgb.b - sourceRgb.b) * amount,
- a: sourceRgb.a,
- });
-};
-
-const colorToRgba = (color, alpha) => {
- const { r, g, b, a } = parseColor(color);
- const nextAlpha = clamp(a * alpha, 0, 1);
- return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${Number(nextAlpha.toFixed(3))})`;
-};
-
-export const buildCustomThemeVars = (brandColor) => ({
- "--brand-color": brandColor,
- "--brand-color-hover": mixColor(brandColor, "#FFFFFF", 0.12),
- "--brand-color-active": mixColor(brandColor, "#000000", 0.14),
- "--brand-color-disabled": colorToRgba(brandColor, 0.4),
- "--brand-color-focus": colorToRgba(brandColor, 0.18),
- "--brand-color-light": colorToRgba(brandColor, 0.12),
- "--text-color-brand": brandColor,
- "--text-color-link": brandColor,
- "--el-color-primary": brandColor,
- "--el-color-primary-light-3": mixColor(brandColor, "#FFFFFF", 0.12),
- "--el-color-primary-light-5": mixColor(brandColor, "#FFFFFF", 0.28),
- "--el-color-primary-light-7": mixColor(brandColor, "#FFFFFF", 0.48),
- "--el-color-primary-light-8": mixColor(brandColor, "#FFFFFF", 0.64),
- "--el-color-primary-light-9": mixColor(brandColor, "#FFFFFF", 0.82),
- "--el-color-primary-dark-2": mixColor(brandColor, "#000000", 0.14),
- "--el-menu-hover-bg-color": colorToRgba(brandColor, 0.12),
- "--el-menu-active-color": brandColor,
- "--el-menu-active-background": colorToRgba(brandColor, 0.12),
- "--el-text-color-secondary": brandColor,
-});
diff --git a/src/stores/theme/buildThemeVars.js b/src/stores/theme/buildThemeVars.js
new file mode 100644
index 0000000..c80d127
--- /dev/null
+++ b/src/stores/theme/buildThemeVars.js
@@ -0,0 +1,102 @@
+const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
+
+const toHex2 = (n) =>
+ clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
+
+const parseHex = (hex) => {
+ const v = (hex || "").trim().replace("#", "");
+ if (v.length === 3) {
+ const [r, g, b] = v.split("").map((c) => c + c);
+ return { r: parseInt(r, 16), g: parseInt(g, 16), b: parseInt(b, 16) };
+ }
+ return {
+ r: parseInt(v.slice(0, 2), 16),
+ g: parseInt(v.slice(2, 4), 16),
+ b: parseInt(v.slice(4, 6), 16),
+ };
+};
+
+const mix = (hex, target, amount) => {
+ const s = parseHex(hex);
+ const t = parseHex(target);
+ return (
+ "#" +
+ toHex2(s.r + (t.r - s.r) * amount) +
+ toHex2(s.g + (t.g - s.g) * amount) +
+ toHex2(s.b + (t.b - s.b) * amount)
+ ).toUpperCase();
+};
+
+const rgbaOf = (hex, a) => {
+ const { r, g, b } = parseHex(hex);
+ return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;
+};
+
+// 单一数据源:每个预设主题的 brandColor + 渐变端点 + 弹窗 UI 字段
+export const PRESET_THEMES = {
+ blue: {
+ borderColor: "#3289FB",
+ gradientTo: "#77B2FF",
+ boxShadow: "0 4px 12px 0 rgba(50, 137, 251, 0.5)",
+ name: "穹宇蓝",
+ desc: "深邃星空\n科技感十足",
+ },
+ purple: {
+ borderColor: "#6155F5",
+ gradientTo: "#9B93FF",
+ boxShadow: "0 4px 12px 0 rgba(97, 85, 245, 0.5)",
+ name: "星轨紫",
+ desc: "神秘优雅\n梦幻氛围",
+ },
+ red: {
+ borderColor: "#DE6262",
+ gradientTo: "#FF988B",
+ boxShadow: "0 4px 12px 0 rgba(222, 98, 98, 0.5)",
+ name: "流金粉",
+ desc: "晨雾染金\n自带光芒",
+ },
+};
+
+export const DEFAULT_THEME = "blue";
+
+export const normalizeThemeValue = (v) =>
+ PRESET_THEMES[v] ? v : DEFAULT_THEME;
+
+export const buildLinearRight = (themeValue) => {
+ const t = PRESET_THEMES[normalizeThemeValue(themeValue)];
+ return `linear-gradient(to right, ${t.borderColor} 0%, ${t.gradientTo} 100%)`;
+};
+
+export const buildPreview = (themeValue) => {
+ const t = PRESET_THEMES[normalizeThemeValue(themeValue)];
+ return `linear-gradient(to bottom, ${t.borderColor} 0%, ${t.gradientTo} 100%)`;
+};
+
+export const buildThemeVars = (themeValue) => {
+ const t = PRESET_THEMES[normalizeThemeValue(themeValue)];
+ const c = t.borderColor;
+
+ return {
+ "--brand-color": c,
+ "--brand-color-bg": rgbaOf(c, 0.21),
+ "--brand-color-hover": mix(c, "#FFFFFF", 0.12),
+ "--brand-color-active": mix(c, "#000000", 0.14),
+ "--brand-color-disabled": rgbaOf(c, 0.4),
+ "--brand-color-focus": rgbaOf(c, 0.18),
+ "--brand-color-light": rgbaOf(c, 0.12),
+ "--text-color-brand": c,
+ "--text-color-link": c,
+ "--el-color-primary": c,
+ "--el-color-primary-light-3": mix(c, "#FFFFFF", 0.2),
+ "--el-color-primary-light-5": mix(c, "#FFFFFF", 0.28),
+ "--el-color-primary-light-7": mix(c, "#FFFFFF", 0.48),
+ "--el-color-primary-light-8": mix(c, "#FFFFFF", 0.64),
+ "--el-color-primary-light-9": mix(c, "#FFFFFF", 0.82),
+ "--el-color-primary-dark-2": mix(c, "#000000", 0.14),
+ "--el-menu-hover-bg-color": rgbaOf(c, 0.12),
+ "--el-menu-active-color": c,
+ "--el-menu-active-background": rgbaOf(c, 0.12),
+ "--el-text-color-secondary": c,
+ "--linear-right": buildLinearRight(normalizeThemeValue(themeValue)),
+ };
+};
\ No newline at end of file
diff --git a/src/stores/theme/index.js b/src/stores/theme/index.js
index 8aa6984..4318af6 100644
--- a/src/stores/theme/index.js
+++ b/src/stores/theme/index.js
@@ -1,89 +1,36 @@
import { defineStore } from "pinia";
import { ref } from "vue";
-import { buildCustomThemeVars } from "./buildCustomThemeVars.js";
-
-const DEFAULT_THEME = "blue"; // 默认主题
-const CUSTOM_THEME = "custom";
-const CUSTOM_THEME_COLOR_KEY = "customThemeColor";
-const THEME_CLASS_MAP = {
- blue: "",
- purple: "purple",
- red: "red",
- black: "dark",
- dark: "dark",
- custom: "",
-};
-
-const normalizeThemeValue = (theme) => {
- return THEME_CLASS_MAP[theme] !== undefined ? theme : DEFAULT_THEME;
-};
-
-const clearCustomThemeVars = (html) => {
- Object.keys(buildCustomThemeVars("#1764FF")).forEach((key) => {
- html.style.removeProperty(key);
- });
+import {
+ buildThemeVars,
+ normalizeThemeValue,
+ DEFAULT_THEME,
+} from "./buildThemeVars.js";
+
+const APPLIED_KEYS = Object.keys(buildThemeVars(DEFAULT_THEME));
+
+const applyToRoot = (theme) => {
+ const root = document.documentElement;
+ // 先清掉旧变量,避免切主题后留下脏数据
+ APPLIED_KEYS.forEach((k) => root.style.removeProperty(k));
+ Object.entries(buildThemeVars(theme)).forEach(([k, v]) =>
+ root.style.setProperty(k, v),
+ );
};
export const useThemeStore = defineStore("theme", () => {
- // 当前主题值,默认为 localStorage 中的值或 DEFAULT_THEME
const themeValue = ref(
normalizeThemeValue(localStorage.getItem("theme") || DEFAULT_THEME),
);
- // 定制主题颜色,默认为 localStorage 中的值或 "#1764FF"
- const customThemeColor = ref(
- localStorage.getItem(CUSTOM_THEME_COLOR_KEY) || "#1764FF",
- );
- // 应用主题
- const applyTheme = (theme) => {
- const normalizedTheme = normalizeThemeValue(theme);
- const html = document.documentElement;
- const themeClass = THEME_CLASS_MAP[normalizedTheme] || "";
- // 预设主题通过 class 切换
- html.className = themeClass;
- clearCustomThemeVars(html);
- // 定制主题通过 CSS 变量实现
- if (normalizedTheme === CUSTOM_THEME) {
- const customVars = buildCustomThemeVars(customThemeColor.value);
- Object.entries(customVars).forEach(([key, value]) => {
- html.style.setProperty(key, value);
- });
- }
- };
- // 设置自定义主题颜色
- const setCustomThemeColor = (color) => {
- customThemeColor.value = color;
- localStorage.setItem(CUSTOM_THEME_COLOR_KEY, color);
-
- if (themeValue.value === CUSTOM_THEME) {
- applyTheme(CUSTOM_THEME);
- }
- };
-
- const previewCustomThemeColor = (color) => {
- customThemeColor.value = color;
-
- if (themeValue.value === CUSTOM_THEME) {
- applyTheme(CUSTOM_THEME);
- }
- };
const setTheme = (theme) => {
- const normalizedTheme = normalizeThemeValue(theme);
- themeValue.value = normalizedTheme;
- localStorage.setItem("theme", normalizedTheme);
- applyTheme(normalizedTheme);
+ const next = normalizeThemeValue(theme);
+ themeValue.value = next;
+ localStorage.setItem("theme", next);
+ applyToRoot(next);
};
- // 初始化时应用默认主题
- applyTheme(themeValue.value);
+ // 初始化:setup 内即生效,不依赖外部调用
+ applyToRoot(themeValue.value);
- return {
- themeValue,
- customThemeColor,
- setTheme,
- setCustomThemeColor,
- previewCustomThemeColor,
- applyTheme,
- buildCustomThemeVars,
- };
-});
+ return { themeValue, setTheme };
+});
\ No newline at end of file
diff --git a/src/views/admin/accounts/list.vue b/src/views/admin/accounts/list.vue
index 8052404..edd4222 100644
--- a/src/views/admin/accounts/list.vue
+++ b/src/views/admin/accounts/list.vue
@@ -7,7 +7,7 @@
{{ t.label }}