275 lines
7.8 KiB
Vue
275 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
|
||
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
|
||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||
import {
|
||
loadThemeState,
|
||
saveThemeState,
|
||
applyThemeState,
|
||
type ThemeName,
|
||
type ThemeState,
|
||
} from "@/utils/theme";
|
||
|
||
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
|
||
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
|
||
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
|
||
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
|
||
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
|
||
{ name: "teal", label: "松石绿", color: "#0d9488" },
|
||
];
|
||
|
||
const wrapEl = ref<HTMLElement | null>(null);
|
||
const open = ref(false);
|
||
const state = ref<ThemeState>(loadThemeState());
|
||
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
|
||
applyThemeState(state.value);
|
||
|
||
function commit(patch: Partial<ThemeState>) {
|
||
state.value = { ...state.value, ...patch };
|
||
applyThemeState(state.value);
|
||
saveThemeState(state.value);
|
||
}
|
||
|
||
function toggle(ev: MouseEvent) {
|
||
ev.stopPropagation();
|
||
open.value = !open.value;
|
||
}
|
||
|
||
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
|
||
function onDocClick(ev: MouseEvent) {
|
||
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
|
||
open.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => document.addEventListener("click", onDocClick, true));
|
||
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="wrapEl" class="theme-wrap">
|
||
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
|
||
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
|
||
</svg>
|
||
<span>配色</span>
|
||
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<div class="theme-pop" :class="{ open }">
|
||
<p class="tp-head">主题色</p>
|
||
<div class="tp-grid">
|
||
<button
|
||
v-for="t in THEME_META"
|
||
:key="t.name"
|
||
type="button"
|
||
class="theme-opt"
|
||
:class="{ active: state.theme === t.name }"
|
||
@click="commit({ theme: t.name })"
|
||
>
|
||
<i :style="{ background: t.color }"></i>{{ t.label }}
|
||
</button>
|
||
</div>
|
||
<div class="tp-sep"></div>
|
||
<p class="tp-head">侧栏样式</p>
|
||
<label class="tp-row">
|
||
<span>侧栏跟随顶栏色</span>
|
||
<span class="sw">
|
||
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
|
||
<i></i>
|
||
</span>
|
||
</label>
|
||
<label class="tp-row" style="margin-top: 9px">
|
||
<span>侧栏深色(关=浅色)</span>
|
||
<span class="sw">
|
||
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
|
||
<i></i>
|
||
</span>
|
||
</label>
|
||
<div class="tp-sep"></div>
|
||
<label class="tp-row">
|
||
<span>顶部过渡色(渐变)</span>
|
||
<span class="sw">
|
||
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
|
||
<i></i>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
|
||
.theme-wrap {
|
||
position: relative;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.theme-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 30px;
|
||
padding: 0 11px;
|
||
cursor: pointer;
|
||
border: 1px solid rgba(255, 255, 255, 0.34);
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.14);
|
||
color: #fff;
|
||
font-size: 12.5px;
|
||
transition: background 0.16s, border-color 0.16s;
|
||
}
|
||
.theme-btn:hover,
|
||
.theme-btn.on {
|
||
background: rgba(255, 255, 255, 0.26);
|
||
border-color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
.theme-btn svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
display: block;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 1.7;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
.theme-btn svg circle {
|
||
fill: currentColor;
|
||
stroke: none;
|
||
}
|
||
.theme-btn .caret {
|
||
width: 11px;
|
||
height: 11px;
|
||
opacity: 0.85;
|
||
transition: transform 0.18s;
|
||
}
|
||
.theme-btn.on .caret {
|
||
transform: rotate(180deg);
|
||
}
|
||
.theme-pop {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
right: 0;
|
||
width: 224px;
|
||
background: #fff;
|
||
color: var(--text, #1e2635);
|
||
border-radius: 12px;
|
||
border: 1px solid var(--line, #e9edf5);
|
||
padding: 12px;
|
||
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transform: translateY(-6px);
|
||
transition: 0.18s;
|
||
z-index: 40;
|
||
}
|
||
.theme-pop.open {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transform: translateY(0);
|
||
}
|
||
.tp-head {
|
||
font-size: 11.5px;
|
||
color: var(--text-3, #98a1b2);
|
||
letter-spacing: 0.5px;
|
||
margin: 0 2px 8px;
|
||
}
|
||
.tp-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px;
|
||
}
|
||
.theme-opt {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
height: 32px;
|
||
padding: 0 8px;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
border: 1px solid var(--line, #e9edf5);
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
color: var(--text-2, #5a6478);
|
||
font-size: 12px;
|
||
transition: 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.theme-opt:hover {
|
||
border-color: #cfd7e6;
|
||
background: #fafbfe;
|
||
}
|
||
.theme-opt i {
|
||
width: 13px;
|
||
height: 13px;
|
||
border-radius: 50%;
|
||
flex: 0 0 auto;
|
||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.theme-opt.active {
|
||
border-color: var(--brand, #577afb);
|
||
color: var(--brand, #577afb);
|
||
background: var(--brand-soft, #eef2ff);
|
||
font-weight: 600;
|
||
}
|
||
.tp-sep {
|
||
height: 1px;
|
||
background: var(--line, #e9edf5);
|
||
margin: 12px 0 10px;
|
||
}
|
||
.tp-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 12.5px;
|
||
color: var(--text-2, #5a6478);
|
||
cursor: pointer;
|
||
padding: 0 2px;
|
||
}
|
||
.sw {
|
||
position: relative;
|
||
width: 38px;
|
||
height: 21px;
|
||
flex: 0 0 auto;
|
||
}
|
||
.sw input {
|
||
position: absolute;
|
||
inset: 0;
|
||
opacity: 0;
|
||
margin: 0;
|
||
cursor: pointer;
|
||
z-index: 1;
|
||
}
|
||
.sw i {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 999px;
|
||
background: #d7dce6;
|
||
transition: background 0.2s;
|
||
pointer-events: none;
|
||
}
|
||
.sw i::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 2.5px;
|
||
left: 2.5px;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
|
||
transition: transform 0.2s;
|
||
}
|
||
.sw input:checked + i {
|
||
background: var(--brand, #577afb);
|
||
}
|
||
.sw input:checked + i::after {
|
||
transform: translateX(17px);
|
||
}
|
||
</style>
|