diff --git a/.gitignore b/.gitignore index 1c2a6eb..0d662f4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ node_modules/ # 前端构建产物不入库,仅保留 .gitkeep 以满足 go:embed web/dist/* !web/dist/.gitkeep + +art/ \ No newline at end of file diff --git a/web/frontend/src/assets/paranoia/ch01.jpg b/web/frontend/src/assets/paranoia/ch01.jpg new file mode 100644 index 0000000..948d91d Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch01.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch02.jpg b/web/frontend/src/assets/paranoia/ch02.jpg new file mode 100644 index 0000000..0a897b1 Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch02.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch03.jpg b/web/frontend/src/assets/paranoia/ch03.jpg new file mode 100644 index 0000000..c75f76e Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch03.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch04.jpg b/web/frontend/src/assets/paranoia/ch04.jpg new file mode 100644 index 0000000..698b020 Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch04.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch05.jpg b/web/frontend/src/assets/paranoia/ch05.jpg new file mode 100644 index 0000000..33f9b5d Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch05.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch06.jpg b/web/frontend/src/assets/paranoia/ch06.jpg new file mode 100644 index 0000000..7c71ed0 Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch06.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch07.jpg b/web/frontend/src/assets/paranoia/ch07.jpg new file mode 100644 index 0000000..a84a03c Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch07.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch08.jpg b/web/frontend/src/assets/paranoia/ch08.jpg new file mode 100644 index 0000000..4ecb7c3 Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch08.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch09.jpg b/web/frontend/src/assets/paranoia/ch09.jpg new file mode 100644 index 0000000..7c18ab6 Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch09.jpg differ diff --git a/web/frontend/src/assets/paranoia/ch10.jpg b/web/frontend/src/assets/paranoia/ch10.jpg new file mode 100644 index 0000000..3f123a5 Binary files /dev/null and b/web/frontend/src/assets/paranoia/ch10.jpg differ diff --git a/web/frontend/src/assets/theme-paranoia.css b/web/frontend/src/assets/theme-paranoia.css new file mode 100644 index 0000000..7538ee2 --- /dev/null +++ b/web/frontend/src/assets/theme-paranoia.css @@ -0,0 +1,569 @@ +/* ============================================================ + 妄想症 · Paranoia 主题(致敬篇) + ------------------------------------------------------------ + 在 上挂 .paranoia 即启用,可与 .dark 叠加: + + html.paranoia 现实 —— 白之章,近乎空白的纸色 + html.paranoia.dark 妄想 —— 黑之章,石蒜红与深渊 + + 取色依据原作 PV 的用色习惯: + 石蒜红 #e0245e 彼岸花与零羽 + 洛天依蓝 #66ccff 泠珞(也是 goodBaby 原本的品牌色) + 言和青 #7ee8e0 / 言和黄 #f2d16b 颜语的守护者与加害者两面 + ============================================================ */ + +html.paranoia { + /* 标题用衬线体,制造「档案 / 卷宗」的观感 */ + --pa-serif: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', STSong, SimSun, serif; + --pa-mono: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace; + --pa-crimson: #e0245e; + --pa-crimson-soft: #f2568a; + --pa-blue: #66ccff; + --pa-cyan: #7ee8e0; + --pa-yellow: #f2d16b; + --pa-violet: #b98cff; + /* 卡片不再是圆润的「气泡」,而是切边的纸片 */ + --gb-radius-card: 6px; +} + +/* ---------- 现实:白之章 ---------- */ + +html.paranoia:not(.dark) { + --gb-primary: #b3163c; + --gb-primary-deep: #8c112f; + --gb-ink: #17121a; + --gb-ink-soft: #2a1c25; + --gb-bg: #f5f1ee; + --gb-card: #fffdfc; + --gb-shadow-card: 0 1px 2px rgb(90 24 48 / 0.05), 0 1px 10px rgb(90 24 48 / 0.05); + --gb-shadow-hover: 0 10px 26px rgb(120 20 50 / 0.14); + + --pa-line: rgb(179 22 60 / 0.16); + --pa-line-soft: rgb(179 22 60 / 0.08); + --pa-line-strong: rgb(179 22 60 / 0.36); + --pa-glow: rgb(179 22 60 / 0.16); + --pa-ghost: rgb(43 20 32 / 0.055); + --pa-grain-opacity: 0.05; + --pa-veil: rgb(255 253 252 / 0.72); + + --el-color-primary: #b3163c; + --el-color-primary-light-3: #c94f6d; + --el-color-primary-light-5: #d98899; + --el-color-primary-light-7: #e8b4bf; + --el-color-primary-light-8: #f0cad2; + --el-color-primary-light-9: #f8e7ea; + --el-color-primary-dark-2: #8f1230; + + --el-bg-color: #fffdfc; + --el-bg-color-page: #f5f1ee; + --el-bg-color-overlay: #fffdfc; + --el-text-color-primary: #2b2028; + --el-text-color-regular: #4d3f47; + --el-text-color-secondary: #8a7880; + --el-text-color-placeholder: #a8969e; + --el-border-color: rgb(179 22 60 / 0.2); + --el-border-color-light: rgb(179 22 60 / 0.14); + --el-border-color-lighter: rgb(179 22 60 / 0.1); + --el-border-color-extra-light: rgb(179 22 60 / 0.08); + --el-fill-color: rgb(179 22 60 / 0.06); + --el-fill-color-light: rgb(179 22 60 / 0.04); + --el-fill-color-lighter: rgb(179 22 60 / 0.025); + --el-fill-color-blank: #fffdfc; + --el-mask-color: rgb(43 20 32 / 0.42); +} + +/* ---------- 妄想:黑之章 ---------- */ + +html.paranoia.dark { + --gb-primary: #e0245e; + /* 深底之上需要更亮的强调色才读得清 */ + --gb-primary-deep: #f2568a; + --gb-ink: #100a12; + --gb-ink-soft: #1d0f1b; + --gb-bg: #08070c; + --gb-card: #150f19; + --gb-shadow-card: 0 1px 2px rgb(0 0 0 / 0.5); + --gb-shadow-hover: 0 10px 30px rgb(0 0 0 / 0.55), 0 0 0 1px rgb(224 36 94 / 0.14); + + --pa-line: rgb(224 36 94 / 0.24); + --pa-line-soft: rgb(224 36 94 / 0.12); + --pa-line-strong: rgb(224 36 94 / 0.5); + --pa-glow: rgb(224 36 94 / 0.34); + --pa-ghost: rgb(236 230 239 / 0.045); + --pa-grain-opacity: 0.075; + --pa-veil: rgb(10 8 14 / 0.72); + + --el-color-primary: #e0245e; + --el-color-primary-light-3: #e6507e; + --el-color-primary-light-5: #ec7c9f; + --el-color-primary-light-7: #f2a8bf; + --el-color-primary-light-8: #f6bfd0; + --el-color-primary-light-9: #2a1622; + --el-color-primary-dark-2: #b31d4b; + + --el-bg-color: #150f19; + --el-bg-color-page: #08070c; + --el-bg-color-overlay: #1b1220; + --el-text-color-primary: #ece6ef; + --el-text-color-regular: #cabfcb; + --el-text-color-secondary: #9a8b98; + --el-text-color-placeholder: #6f616d; + --el-border-color: rgb(224 36 94 / 0.26); + --el-border-color-light: rgb(224 36 94 / 0.18); + --el-border-color-lighter: rgb(224 36 94 / 0.14); + --el-border-color-extra-light: rgb(224 36 94 / 0.1); + --el-fill-color: rgb(224 36 94 / 0.12); + --el-fill-color-light: rgb(224 36 94 / 0.08); + --el-fill-color-lighter: rgb(224 36 94 / 0.05); + --el-fill-color-blank: #150f19; + --el-mask-color: rgb(4 3 6 / 0.66); +} + +/* ---------- 底色:纸纹与深渊雾气 ---------- */ + +html.paranoia, +html.paranoia body, +html.paranoia #app { + background: transparent; +} + +html.paranoia body { + background-color: var(--gb-bg); + background-image: + radial-gradient(1100px 620px at 8% -12%, rgb(224 36 94 / 0.1), transparent 62%), + radial-gradient(900px 520px at 102% 4%, rgb(102 204 255 / 0.07), transparent 58%), + radial-gradient(760px 760px at 50% 118%, rgb(224 36 94 / 0.08), transparent 60%); + background-attachment: fixed; +} + +/* ---------- 排版 ---------- */ + +html.paranoia .page-header h2, +html.paranoia .el-card__header, +html.paranoia .el-dialog__title, +html.paranoia h1, +html.paranoia h2, +html.paranoia h3, +html.paranoia .pa-display { + font-family: var(--pa-serif); + letter-spacing: 0.02em; +} + +html.paranoia .page-header h2 { + font-weight: 700; + /* 标题下压一道极细的红线,像卷宗上的分割线 */ + padding-bottom: 6px; + border-bottom: 1px solid var(--pa-line); +} + +html.paranoia .mono, +html.paranoia .stat-value, +html.paranoia .countdown-num, +html.paranoia .ring-pct { + font-variant-numeric: tabular-nums; +} + +/* ---------- 卡片:切边的纸片 ---------- */ + +html.paranoia .el-card { + border: 1px solid var(--pa-line); + box-shadow: var(--gb-shadow-card); + background: var(--gb-card); +} + +html.paranoia .el-card__header { + border-bottom: 1px solid var(--pa-line); + font-weight: 600; +} + +html.paranoia:not(.dark) .el-card { + background-image: linear-gradient(180deg, rgb(255 255 255 / 0.6), rgb(255 252 251 / 0.2)); +} + +html.paranoia.dark .el-card { + background-image: linear-gradient(180deg, rgb(224 36 94 / 0.05), transparent 42%); + backdrop-filter: blur(6px); +} + +/* ---------- 按钮 ---------- */ + +html.paranoia .el-button { + border-radius: 4px; +} + +html.paranoia .el-button--primary { + --el-button-text-color: #fff7fa; + --el-button-hover-text-color: #fff; + --el-button-active-text-color: #fff; + --el-button-bg-color: var(--gb-primary); + --el-button-border-color: var(--gb-primary); + --el-button-hover-bg-color: var(--pa-crimson-soft); + --el-button-hover-border-color: var(--pa-crimson-soft); + letter-spacing: 0.08em; + font-weight: 600; +} + +html.paranoia.dark .el-button--primary { + --el-button-text-color: #fff; +} + +html.paranoia .el-button--default:not(.is-disabled) { + background: transparent; + border-color: var(--pa-line); + color: var(--el-text-color-regular); +} + +html.paranoia .el-button--default:not(.is-disabled):hover { + border-color: var(--pa-line-strong); + color: var(--gb-primary-deep); + background: var(--el-fill-color-light); +} + +/* ---------- 表单与表格 ---------- */ + +html.paranoia .el-input__wrapper, +html.paranoia .el-textarea__inner, +html.paranoia .el-select__wrapper { + border-radius: 4px; + background: transparent; +} + +html.paranoia .el-table { + --el-table-border-color: var(--pa-line-soft); + --el-table-header-bg-color: transparent; + --el-table-row-hover-bg-color: var(--el-fill-color-light); + background: transparent; +} + +html.paranoia .el-table tr, +html.paranoia .el-table th.el-table__cell { + background: transparent; +} + +html.paranoia .el-table th.el-table__cell { + font-family: var(--pa-mono); + font-size: 12px; + letter-spacing: 0.08em; + color: var(--el-text-color-secondary); +} + +html.paranoia .el-tag { + border-radius: 3px; + font-family: var(--pa-mono); + letter-spacing: 0.04em; +} + +html.paranoia .el-dialog, +html.paranoia .el-message-box { + border: 1px solid var(--pa-line); + border-radius: 8px; +} + +html.paranoia .el-dialog__header { + border-bottom: 1px solid var(--pa-line-soft); + margin-right: 0; + padding-bottom: 14px; +} + +html.paranoia .el-alert { + border-radius: 4px; + border: 1px solid var(--pa-line); +} + +html.paranoia .el-empty__description { + letter-spacing: 0.08em; +} + +html.paranoia .el-loading-mask { + background-color: var(--pa-veil); +} + +/* 键盘焦点环改用石蒜红 */ +html.paranoia :focus-visible { + outline: 2px solid var(--gb-primary); + outline-offset: 2px; +} + +/* ---------- 滚动条 ---------- */ + +html.paranoia ::-webkit-scrollbar-thumb { + background: rgb(224 36 94 / 0.35); +} + +html.paranoia ::-webkit-scrollbar-thumb:hover { + background: rgb(224 36 94 / 0.55); +} + +/* ============================================================ + 主题通用零件 + ============================================================ */ + +/* 章节记号:b(坠落之章)/ #(上升之章) */ +.pa-mark { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: var(--pa-mono); + font-size: 11px; + line-height: 1; + letter-spacing: 0.08em; + padding: 3px 6px; + border: 1px solid var(--pa-line); + border-radius: 3px; + color: var(--el-text-color-secondary); + white-space: nowrap; +} + +.pa-mark em { + font-style: normal; + color: var(--gb-primary); + font-weight: 700; +} + +.pa-mark--plain { + border-color: transparent; + padding: 0; +} + +/* 竖排的幽灵字,用作背景里的「妄想」 */ +.pa-ghost-text { + position: absolute; + writing-mode: vertical-rl; + font-family: var(--pa-serif); + font-weight: 700; + color: var(--pa-ghost); + letter-spacing: 0.3em; + user-select: none; + pointer-events: none; + line-height: 1; +} + +/* 细红分割线 */ +.pa-rule { + display: flex; + align-items: center; + gap: 12px; + color: var(--pa-line-strong); +} + +.pa-rule::before, +.pa-rule::after { + content: ''; + flex: 1; + height: 1px; + background: var(--pa-line); +} + +/* 引文块 */ +.pa-quote { + font-family: var(--pa-serif); + line-height: 2; + color: var(--el-text-color-regular); + border-left: 2px solid var(--gb-primary); + padding: 2px 0 2px 16px; +} + +/* 胶片感的图片:统一降饱和,hover 时回血 */ +.pa-film { + position: relative; + overflow: hidden; + background: var(--gb-ink); +} + +.pa-film img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + filter: saturate(0.72) contrast(1.06) brightness(0.94); + transition: + transform 0.6s var(--gb-ease), + filter 0.6s var(--gb-ease); +} + +.pa-film::after { + content: ''; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, transparent 45%, rgb(8 6 10 / 0.78) 100%), + radial-gradient(120% 90% at 50% 10%, transparent 40%, rgb(224 36 94 / 0.16) 100%); + pointer-events: none; +} + +.pa-film:hover img { + transform: scale(1.045); + filter: saturate(1) contrast(1.04) brightness(1); +} + +/* ---------- 背景层 ---------- */ + +.pa-backdrop { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +/* 噪点:让大面积纯色不至于太平 */ +.pa-backdrop__grain { + position: absolute; + inset: -40px; + opacity: var(--pa-grain-opacity); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); + mix-blend-mode: overlay; +} + +/* 暗角 */ +.pa-backdrop__vignette { + position: absolute; + inset: 0; + background: radial-gradient(130% 100% at 50% 40%, transparent 42%, rgb(6 4 9 / 0.5) 100%); +} + +html.paranoia:not(.dark) .pa-backdrop__vignette { + background: radial-gradient(130% 100% at 50% 40%, transparent 55%, rgb(120 60 80 / 0.16) 100%); +} + +/* 缓慢下移的扫描光带 */ +.pa-backdrop__scan { + position: absolute; + left: 0; + right: 0; + height: 34vh; + background: linear-gradient(180deg, transparent, rgb(224 36 94 / 0.07) 45%, transparent); + animation: pa-scan 26s linear infinite; +} + +@keyframes pa-scan { + 0% { + transform: translate3d(0, -40vh, 0); + } + 100% { + transform: translate3d(0, 120vh, 0); + } +} + +/* 石蒜花瓣 */ +.pa-backdrop__petals { + position: absolute; + inset: 0; +} + +.pa-petal { + position: absolute; + top: 0; + left: var(--pa-x, 50%); + width: calc(14px * var(--pa-scale, 1)); + height: calc(14px * var(--pa-scale, 1)); + opacity: 0; + animation: pa-fall var(--pa-dur, 22s) linear var(--pa-delay, 0s) infinite; + will-change: transform; +} + +.pa-petal svg { + display: block; + width: 100%; + height: 100%; + animation: pa-spin calc(var(--pa-dur, 22s) / 5) ease-in-out infinite alternate; +} + +/* 深色底上的石蒜红要提亮一点才看得见 */ +html.paranoia.dark .pa-petal svg { + filter: brightness(1.5) saturate(1.15); +} + +@keyframes pa-fall { + 0% { + transform: translate3d(0, -14vh, 0); + opacity: 0; + } + 10% { + opacity: var(--pa-opacity, 0.5); + } + 85% { + opacity: var(--pa-opacity, 0.5); + } + 100% { + transform: translate3d(var(--pa-drift, 40px), 112vh, 0); + opacity: 0; + } +} + +@keyframes pa-spin { + from { + transform: rotate(-24deg); + } + to { + transform: rotate(24deg); + } +} + +/* 呼吸的红色光晕,浮在角落 */ +.pa-orb { + position: absolute; + border-radius: 50%; + filter: blur(70px); + animation: pa-breathe 16s ease-in-out infinite; +} + +@keyframes pa-breathe { + 0%, + 100% { + opacity: 0.42; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.12); + } +} + +/* ---------- 皮肤切换时强制让新画面浮到最上层 ---------- */ + +html.vt-skin::view-transition-old(root) { + z-index: 1; +} + +html.vt-skin::view-transition-new(root) { + z-index: 2147483646; +} + +/* 不支持 View Transitions 的浏览器退化成整页淡变 */ +html.no-vt.paranoia body, +html.no-vt.paranoia .el-card, +html.no-vt.paranoia .el-table tr, +html.no-vt.paranoia .aside, +html.no-vt.paranoia .header, +html.no-vt.paranoia .nav-item { + transition: + background-color 0.36s var(--gb-ease), + border-color 0.36s var(--gb-ease), + color 0.36s var(--gb-ease); +} + +/* ---------- 响应式 ---------- */ + +@media (max-width: 768px) { + /* 小屏只留四片花瓣,省电也省注意力 */ + .pa-backdrop__petals .pa-petal:nth-child(n + 5) { + display: none; + } + + html.paranoia .page-header h2 { + font-size: 19px; + } +} + +@media (prefers-reduced-motion: reduce) { + .pa-petal, + .pa-backdrop__scan, + .pa-orb { + animation: none; + } + + .pa-petal { + opacity: 0; + } +} diff --git a/web/frontend/src/components/ParanoiaBackdrop.vue b/web/frontend/src/components/ParanoiaBackdrop.vue new file mode 100644 index 0000000..31c7432 --- /dev/null +++ b/web/frontend/src/components/ParanoiaBackdrop.vue @@ -0,0 +1,85 @@ + + + diff --git a/web/frontend/src/components/ThemePicker.vue b/web/frontend/src/components/ThemePicker.vue new file mode 100644 index 0000000..b23cb99 --- /dev/null +++ b/web/frontend/src/components/ThemePicker.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/web/frontend/src/composables/useTheme.ts b/web/frontend/src/composables/useTheme.ts index 0a00431..2bbd4c1 100644 --- a/web/frontend/src/composables/useTheme.ts +++ b/web/frontend/src/composables/useTheme.ts @@ -1,23 +1,85 @@ -import { ref } from 'vue' +import { computed, ref } from 'vue' -const STORAGE_KEY = 'gb-theme' +/** 明暗模式的存储键 */ +const DARK_STORAGE_KEY = 'gb-theme' +/** 外观皮肤(主题)的存储键 */ +const SKIN_STORAGE_KEY = 'gb-skin' + +/** + * 可选皮肤: + * - default:原有的晴空配色 + * - paranoia:致敬《妄想症 Paranoia》系列曲的外观主题 + */ +export type ThemeSkin = 'default' | 'paranoia' + +export interface SkinOption { + id: ThemeSkin + /** 皮肤名 */ + name: string + /** 一句话说明 */ + sub: string + /** 预览用的色板 */ + swatches: string[] +} + +export const skinOptions: SkinOption[] = [ + { + id: 'default', + name: '晴空', + sub: 'goodBaby 默认外观', + swatches: ['#66ccff', '#3fa9e0', '#232630'], + }, + { + id: 'paranoia', + name: '妄想症', + sub: 'Paranoia · 九重档案', + swatches: ['#e0245e', '#7ee8e0', '#f2d16b'], + }, +] /** 全局共享的主题状态,模块级单例,任何组件读到的都是同一份 */ const isDark = ref(false) +const skin = ref('default') +const isParanoia = computed(() => skin.value === 'paranoia') -/** 把主题应用到 上 */ -function applyTheme(dark: boolean) { +function readStorage(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + // 隐私模式 / 禁用存储时静默降级为「本次会话内有效」 + return null + } +} + +function writeStorage(key: string, value: string) { + try { + localStorage.setItem(key, value) + } catch { + // 同上 + } +} + +/** 把明暗模式应用到 上 */ +function applyDark(dark: boolean) { document.documentElement.classList.toggle('dark', dark) isDark.value = dark } +/** 把皮肤应用到 上,配色覆盖写在 theme-paranoia.css 里 */ +function applySkin(next: ThemeSkin) { + document.documentElement.classList.toggle('paranoia', next === 'paranoia') + skin.value = next +} + /** * 在应用启动时调用一次:恢复上次选择,没有记录时跟随系统。 */ export function initTheme() { - const saved = localStorage.getItem(STORAGE_KEY) + const savedDark = readStorage(DARK_STORAGE_KEY) const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches - applyTheme(saved ? saved === 'dark' : prefersDark) + applyDark(savedDark ? savedDark === 'dark' : prefersDark) + + applySkin(readStorage(SKIN_STORAGE_KEY) === 'paranoia' ? 'paranoia' : 'default') // 标记浏览器是否支持圆形揭示,供 CSS 决定要不要退化成淡变 if (typeof document.startViewTransition !== 'function') { @@ -26,66 +88,113 @@ export function initTheme() { // 用户没有手动选择过时,跟随系统变化 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { - if (!localStorage.getItem(STORAGE_KEY)) { - applyTheme(e.matches) + if (!readStorage(DARK_STORAGE_KEY)) { + applyDark(e.matches) } }) } +/** + * 用一次圆形揭示动画包住外观切换。 + * + * 支持 View Transitions 时以点击处为圆心做圆形揭示;不支持(Firefox / Safari 旧版) + * 或用户要求减少动效时直接切换。 + * + * @param mutate 真正改变外观状态的函数 + * @param event 触发切换的点击事件,用来决定圆心 + * @param mode dark/light 沿用原有的进/退方向;skin 需要额外把新画面提到最上层 + */ +async function reveal( + mutate: () => void, + event: MouseEvent | undefined, + mode: 'dark' | 'light' | 'skin', +) { + const startViewTransition = document.startViewTransition?.bind(document) + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + + if (!startViewTransition || reduceMotion) { + mutate() + return + } + + // 换皮肤时新旧画面可能同为暗色,靠这个类强制让新画面浮到上面 + if (mode === 'skin') { + document.documentElement.classList.add('vt-skin') + } + + // 从点击点向外扩散;没有事件对象时从右上角(切换按钮所在位置)扩散 + const x = event?.clientX ?? window.innerWidth - 60 + const y = event?.clientY ?? 30 + // 半径取到最远的那个角,保证能覆盖整个视口 + const endRadius = Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y), + ) + + const transition = startViewTransition(mutate) + + try { + await transition.ready + } catch { + // 浏览器跳过了这次过渡(页面不可见、连续快速点击等)。 + // 此时外观已由 mutate 应用好,只是没有揭示动画。 + document.documentElement.classList.remove('vt-skin') + return + } + + const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] + + // 切到深色时让新画面扩散进来;切回浅色时让旧画面收缩出去 + const pseudoElement = + mode === 'light' ? '::view-transition-old(root)' : '::view-transition-new(root)' + + document.documentElement.animate( + { clipPath: mode === 'light' ? [...clipPath].reverse() : clipPath }, + { + duration: 480, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + pseudoElement, + }, + ) + + try { + await transition.finished + } catch { + // finished 在过渡被跳过时也会抛错,忽略即可 + } + document.documentElement.classList.remove('vt-skin') +} + export function useTheme() { /** * 切换明暗主题。 - * - * 支持 View Transitions 时,以点击处为圆心做一次圆形揭示动画; - * 不支持(Firefox / Safari 旧版)或用户要求减少动效时直接切换。 */ async function toggleTheme(event?: MouseEvent) { const next = !isDark.value - const persist = () => { - applyTheme(next) - localStorage.setItem(STORAGE_KEY, next ? 'dark' : 'light') - } - - // 绑定后再判空,TS 才能正确收窄这个可选 API - const startViewTransition = document.startViewTransition?.bind(document) - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - - if (!startViewTransition || reduceMotion) { - persist() - return - } - - // 以点击点为圆心;没有事件对象时从右上角(切换按钮所在位置)扩散 - const x = event?.clientX ?? window.innerWidth - 60 - const y = event?.clientY ?? 30 - // 半径取到最远的那个角,保证能覆盖整个视口 - const endRadius = Math.hypot( - Math.max(x, window.innerWidth - x), - Math.max(y, window.innerHeight - y), - ) - - const transition = startViewTransition(persist) - - try { - await transition.ready - } catch { - // 浏览器跳过了这次过渡(页面不可见、连续快速点击等)。 - // 此时主题已经通过 persist() 应用好了,只是没有揭示动画,直接返回。 - return - } - - const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] - - document.documentElement.animate( - // 切到深色时让新画面扩散进来;切回浅色时让旧画面收缩出去 - { clipPath: next ? clipPath : [...clipPath].reverse() }, - { - duration: 480, - easing: 'cubic-bezier(0.4, 0, 0.2, 1)', - pseudoElement: next ? '::view-transition-new(root)' : '::view-transition-old(root)', + await reveal( + () => { + applyDark(next) + writeStorage(DARK_STORAGE_KEY, next ? 'dark' : 'light') }, + event, + next ? 'dark' : 'light', ) } - return { isDark, toggleTheme } + /** + * 切换外观皮肤。传入点击事件时以点击处为圆心做揭示动画。 + */ + async function setSkin(next: ThemeSkin, event?: MouseEvent) { + if (next === skin.value) return + await reveal( + () => { + applySkin(next) + writeStorage(SKIN_STORAGE_KEY, next) + }, + event, + 'skin', + ) + } + + return { isDark, isParanoia, skin, toggleTheme, setSkin } } diff --git a/web/frontend/src/data/paranoia.ts b/web/frontend/src/data/paranoia.ts new file mode 100644 index 0000000..70f4e24 --- /dev/null +++ b/web/frontend/src/data/paranoia.ts @@ -0,0 +1,456 @@ +/** + * 《妄想症 Paranoia》系列资料 + * + * 致敬对象:DELA 作曲 / 雨狸作词 / Lune 等人曲绘的 VOCALOID 中文原创系列曲 + * (2015.11.28《一重加害》— 2017.03.17《九重现实》,共 475 天; + * 2025.11.28 发布十周年纪念曲《十重告别》)。 + * + * 曲绘、歌词与设定的著作权均归原作者所有,此处仅作主题化的介绍与引用。 + */ + +import ch01 from '@/assets/paranoia/ch01.jpg' +import ch02 from '@/assets/paranoia/ch02.jpg' +import ch03 from '@/assets/paranoia/ch03.jpg' +import ch04 from '@/assets/paranoia/ch04.jpg' +import ch05 from '@/assets/paranoia/ch05.jpg' +import ch06 from '@/assets/paranoia/ch06.jpg' +import ch07 from '@/assets/paranoia/ch07.jpg' +import ch08 from '@/assets/paranoia/ch08.jpg' +import ch09 from '@/assets/paranoia/ch09.jpg' +import ch10 from '@/assets/paranoia/ch10.jpg' + +/** 章:乐曲在系列中的位置 */ +export type ParanoiaArc = '序曲' | '坠落之章' | '上升之章' | '十周年' + +export interface ParanoiaChapter { + /** 重数,0 表示序曲 */ + no: number + /** 中文数字,如「一」 */ + numeral: string + /** 曲名 */ + title: string + arc: ParanoiaArc + /** 乐谱记号:坠落之章为 b(降号),上升之章为 #(升号) */ + sigil: string + /** 投稿日期 */ + date: string + /** 演唱 */ + singers: string + /** 视角角色 */ + voices: string + /** 一句话剧情 */ + summary: string + /** 章节简介 */ + detail: string + /** 歌词摘录 */ + lyric: string + /** 对应的妄想症条目 */ + delusion: string + /** 临床描述 */ + note: string + image: string +} + +/** 三重加害者、守护者、背叛者与第四面墙外之人 */ +export interface ParanoiaCharacter { + name: string + /** 虚拟歌手 */ + vocal: string + role: string + /** 主题色 */ + color: string + /** 第二主题色,用于言语这种双重身份的角色 */ + colorAlt?: string + /** 一句属于她的话 */ + line: string +} + +/** 妄想档案:系列中逐章出现的临床条目 */ +export interface ParanoiaDelusion { + index: string + term: string + alias: string + note: string + chapter: string +} + +export const paranoiaChapters: ParanoiaChapter[] = [ + { + no: 1, + numeral: '一', + title: '一重加害', + arc: '坠落之章', + sigil: 'b', + date: '2015.11.28', + singers: '洛天依 / 言和', + voices: '被害者泠珞 · 加害者颜语', + summary: '熟悉的街道上多了一个影子,被害妄想从此降临。', + detail: + '系列的开篇。以被害妄想为主题,由被害者泠珞、加害者颜语与背叛者零羽三人展开,剧情扑朔迷离,在留下巨大悬念的同时,也为最终的结局埋下伏笔。', + lyric: '别对我说 你身后是谁在揣度\n别靠近我 你这无锁链的恶魔', + delusion: '被害妄想', + note: '患者往往处于恐惧状态而胡乱推理和分析,坚持自己受到迫害或伤害,如言语上的针对、嘲弄、跟踪及监听等。', + image: ch01, + }, + { + no: 2, + numeral: '二', + title: '二重变革', + arc: '坠落之章', + sigil: 'b', + date: '2015.12.12', + singers: '洛天依 / 言和', + voices: '被害者泠珞 · 加害者颜语', + summary: '凶手遵从风声鹤唳者的妄想而来,自虚无中诞生。', + detail: + '第一作的故事后续与不同视角描述。被害者与加害者之间的斗争正式开始:「我正是接受你『会被杀死』的强烈夙愿而自虚无诞生。」', + lyric: '这一重任扭曲泛滥的世界\n它可悲的明天就由我来终结', + delusion: '影响妄想 · 逆', + note: '患者认为自己的思维、情感与意志行为可以影响并支配外界的现实,即「心想事成」。', + image: ch02, + }, + { + no: 3, + numeral: '三', + title: '三重爱恋', + arc: '坠落之章', + sigil: 'b', + date: '2016.01.22', + singers: '洛天依 / 言和', + voices: '被害者泠珞 · 守护者颜语 · 潜意识泠珞', + summary: '杀死幻想怪物之后,她被裁定防卫过当,于是开始了一场妄想中的恋爱。', + detail: + '盲目生活像陀螺一般旋转不停,动荡的视线引发不安。在梦魇深堕之处,她开始相信自己终于遇到了那个人。', + lyric: '约会在吊死鬼开的乐园\n看糖果和孤独成就了饕餮', + delusion: '情爱妄想', + note: '又称 de Clerambault 症候群。病人会以为自己正和某人恋爱,或另一个人深爱着自己;幻想中的恋人甚至只是一个「魅影」。', + image: ch03, + }, + { + no: 4, + numeral: '四', + title: '四重罪孽', + arc: '坠落之章', + sigil: 'b', + date: '2016.03.26', + singers: '洛天依 / 言和 / 乐正绫', + voices: '潜意识泠珞 · 守护者颜语 · 背叛者零羽', + summary: '潜意识泠珞觉醒,察觉一切皆妄想,并宣告自己犯下的重重罪孽。', + detail: + '表现挣扎的一章。加害者的复活、守护者准备为爱牺牲的觉悟、背叛者扑朔迷离的死因,都替之后的剧情留下伏笔。', + lyric: '我犯下了重重罪孽 我到底是什么存在\n我是最恐怖的危害 能否从此逃开', + delusion: '自罪妄想', + note: '又称罪恶妄想。患者毫无根据地认为自己犯了严重错误和罪行,或将一件小事的错误无限夸大,以至于拒食或要求赎罪。', + image: ch04, + }, + { + no: 5, + numeral: '五', + title: '五重空洞', + arc: '坠落之章', + sigil: 'b', + date: '2016.04.28', + singers: '洛天依 / 乐正绫', + voices: '潜意识泠珞 · 背叛者零羽', + summary: '妄想世界的夹缝里,彼岸花田中埋葬着背叛者零羽。', + detail: + '潜意识泠珞在彼岸花田中见到了埋葬在记忆深处的零羽,过去的种种记忆漫上心头。故事的真相揭起一角,过去与未来依旧扑朔迷离。', + lyric: '你停留在我无法追逐的记忆彼岸\n永远不会再回来', + delusion: '内心被揭露感', + note: '又称被洞悉感。患者认为其内心的想法未经表达,别人就知道了;严重时表层意识可能被穿透,导致潜意识暴露在外。', + image: ch05, + }, + { + no: 6, + numeral: '六', + title: '六重不忠', + arc: '上升之章', + sigil: '#', + date: '2016.08.05', + singers: '洛天依 / 言和 / 乐正绫', + voices: '加害者 · 守护者 · 潜意识泠珞', + summary: '守护者先被策反,又因誓言死在加害者刀下,系列由此转入上升之章。', + detail: + '从本曲开始,系列进入 # 之章(上升之章)。她先期待、她先许愿,身为镜像怎能妥协;而誓言与爱,最终以死亡兑现。', + lyric: '再见吧终会相见 莫为此放弃明天\n在绝望之前 将我的誓言默念', + delusion: '嫉妒妄想综合征', + note: '又称奥赛罗综合征。患者坚信所爱之人不忠,即使有确切证据证明其并未背叛,怀疑的依据也捕风捉影。', + image: ch06, + }, + { + no: 7, + numeral: '七', + title: '七重痼病', + arc: '上升之章', + sigil: '#', + date: '2016.12.16', + singers: '洛天依 / 言和 / 乐正绫', + voices: '被害者泠珞 · 加害者颜语 · 零羽', + summary: '加害者与潜意识同归于尽,她在白日的中心失去焦距。', + detail: + '第六作的后续。红石蒜在无人之地盛开,妄议着未来于是溃败。「如果谎言重复了千遍依然无法成为现实,那谎言应该如何生存下去呢?」', + lyric: '世界已发出信号 停止临终前无止境祈祷\n我接受她不在的未来', + delusion: '疑病妄想', + note: '患者毫无根据地坚信自己患了某种严重躯体疾病或不治之症。严重时称为虚无幻想——「本人已经不存在,只剩下一个躯壳空壳了」。', + image: ch07, + }, + { + no: 8, + numeral: '八', + title: '八重回归', + arc: '上升之章', + sigil: '#', + date: '2017.03.10', + singers: '洛天依 / 乐正绫', + voices: '泠珞 · 零羽', + summary: '一切妄想破灭之后回归现实,同时与过往和梦想告别。', + detail: + '睁开眼重新堕入平凡的世界,辗转枯燥的时间一天又一天。所有羁绊终归于平淡,誓言悉数臣服现实的安排——「无意义乃这个世界存在的唯一意义。」', + lyric: '再见 再不见\n无比真实过的一切', + delusion: '回归空白', + note: '现实本身就需要和妄想区分开来。越痛苦,翅膀越充血,它们就越美丽——然后死亡就突然降临,只是轻飘飘地、轻飘飘地。', + image: ch08, + }, + { + no: 9, + numeral: '九', + title: '九重现实', + arc: '上升之章', + sigil: '#', + date: '2017.03.17', + singers: '洛天依 / 乐正绫 · 言和和声', + voices: '泠珞 · 零羽', + summary: '完结作。故事的真正脉络,以及带着希望的未来。', + detail: + '从「与其生离,不如死别」到「面对凶险的今后」。不是自作多情,也不是阳光明媚,仅仅是空白的现实而已。白色,也许是可以着色的,也许是被擦除了色彩之后的。', + lyric: '你的声音击破梦魇一重一重\n自最遥远的彼方呼唤我', + delusion: '现实', + note: '「我所描述的一切或许并非真实,然而我的感受绝无虚假。」绝无虚假。', + image: ch09, + }, + { + no: 10, + numeral: '十', + title: '十重告别', + arc: '十周年', + sigil: '✳', + date: '2025.11.28', + singers: '洛天依 / 言和 / 乐正绫 / 双狐座', + voices: '第四面墙外之人', + summary: '第四面墙外之人回望整个系列,把苦痛与欢愉收纳成歌颂明天的力量。', + detail: + '十周年纪念曲。打破第四面墙的元叙事视角,带领角色、创作者与听众共同回望这一切:告别或是全新的起点,全在他者的一念之间。', + lyric: '无论再见或再也不见 我们心声已然相遇见\n比十年更早之前 我便歌颂着明天', + delusion: '告别', + note: '载体或许会消失,但我们相遇过的回忆不会。无论是十年前还是十年后,都希望你能拥有属于自己的幸福。', + image: ch10, + }, +] + +export const paranoiaCharacters: ParanoiaCharacter[] = [ + { + name: '泠珞', + vocal: '洛天依', + role: '被害者', + color: '#66ccff', + line: '不承认那是命运,命运是被神抛弃的棋局。', + }, + { + name: '颜语', + vocal: '言和', + role: '加害者 / 守护者', + color: '#f2d16b', + colorAlt: '#7ee8e0', + line: '如果就此离开,还要怎么加害。', + }, + { + name: '零羽', + vocal: '乐正绫', + role: '背叛者', + color: '#e0245e', + line: '背对着我一跃而下的女孩,在呼唤。', + }, + { + name: '双狐座', + vocal: '第四面墙外之人', + role: '元叙事视角', + color: '#b98cff', + line: '告别或是全新的起点,全在他者的一念之间。', + }, +] + +export const paranoiaDelusions: ParanoiaDelusion[] = [ + { + index: '01', + term: '被害妄想', + alias: 'delusion of persecution', + note: '处于恐惧状态而胡乱推理和分析,坚持自己受到迫害或伤害,如言语上的针对、嘲弄、跟踪及监听。', + chapter: '一重加害', + }, + { + index: '02', + term: '影响妄想', + alias: 'delusion of influence', + note: '认为自己的思维、情感、意志行为活动受到外界某种力量的支配、控制、操纵,不能自主。', + chapter: '二重变革', + }, + { + index: '03', + term: '影响妄想 · 逆', + alias: 'reverse influence', + note: '认为自己的思维与意志不论多么荒谬,都可以影响并支配外界的现实,即「心想事成」。', + chapter: '二重变革', + }, + { + index: '04', + term: '情爱妄想', + alias: "de Clerambault's syndrome", + note: '以为自己正和某人恋爱,或另一个人深爱着自己;幻想中的恋人甚至只是一个现实中并不存在的「魅影」。', + chapter: '三重爱恋', + }, + { + index: '05', + term: '自罪妄想', + alias: 'delusion of guilt', + note: '又称罪恶妄想。毫无根据地认为自己犯了严重错误和罪行,罪大恶极、死有余辜,应该受到惩罚。', + chapter: '四重罪孽', + }, + { + index: '06', + term: '内心被揭露感', + alias: 'experience of being revealed', + note: '又称被洞悉感。认为内心的想法未经表达别人就知道了;严重时表层意识被穿透,潜意识暴露在外。', + chapter: '五重空洞', + }, + { + index: '07', + term: '投射性认同', + alias: 'projective identification', + note: '诱导他人以一种限定的方式行动或做出反应的人际行为模式,接受者往往被迫成为投射者感受的储存室。', + chapter: '五重空洞', + }, + { + index: '08', + term: '嫉妒妄想', + alias: 'Othello syndrome', + note: '坚信所爱之人不贞,即使有确切证据也丝毫不动摇;思维推理荒谬,怀疑的依据捕风捉影。', + chapter: '六重不忠', + }, + { + index: '09', + term: '疑病妄想', + alias: 'hypochondriacal delusion', + note: '毫无根据地坚信自己患了某种严重躯体疾病或不治之症,反复检查也不能纠正其歪曲的信念。', + chapter: '七重痼病', + }, + { + index: '10', + term: '虚无幻想', + alias: 'nihilistic delusion', + note: '疑病妄想的重度形态:认为「本人已经不存在,只剩下一个躯壳空壳了」。', + chapter: '七重痼病', + }, +] + +/** 《九重现实》中对妄想的五条描述 */ +export const paranoiaAxioms: string[] = [ + '妄想是一个根本性障碍。', + '是慢性和终身频发的。', + '妄想有逻辑构造,并且内部成立。', + '妄想和普通逻辑推理不冲突,而且没有一般的行为异常。', + '自身经历了对自我参考的高度理解——对别人来说不重要的事件,对他们来说极为重要。', +] + +/** 主题化引文,用于顶栏滚动字幕与仪表盘签文 */ +export const paranoiaQuotes: { text: string; from: string }[] = [ + { text: '谎言重复一千次就变成真理。所以,现实也是这样诞生的。', from: '《一重加害》' }, + { text: '梦魇中是谁背对着我纵身一跃,没说再见,也没有遗言。', from: '《一重加害》' }, + { text: '这一重任扭曲泛滥的世界,它可悲的明天就由我来终结。', from: '《二重变革》' }, + { text: '约会在吊死鬼开的乐园,看糖果和孤独成就了饕餮。', from: '《三重爱恋》' }, + { text: '我犯下了重重罪孽,我到底是什么存在。', from: '《四重罪孽》' }, + { text: '你停留在我无法追逐的记忆彼岸,永远不会再回来。', from: '《五重空洞》' }, + { text: '再见吧终会相见,莫为此放弃明天。', from: '《六重不忠》' }, + { text: '如果谎言重复了千遍依然无法成为现实,那谎言应该如何生存下去呢?', from: '《七重痼病》' }, + { text: '无意义乃这个世界存在的唯一意义。', from: '《八重回归》' }, + { text: '我所描述的一切或许并非真实,然而我的感受绝无虚假。', from: '《九重现实》' }, + { text: '比十年更早之前,我便歌颂着明天。', from: '《十重告别》' }, +] + +/** 曲目列表:同系列其他作品 */ +export const paranoiaSeries: { group: string; items: string[] }[] = [ + { group: '序曲', items: ['《NOVA》'] }, + { + group: '正篇', + items: [ + '《一重加害》', + '《二重变革》', + '《三重爱恋》', + '《四重罪孽》', + '《五重空洞》', + '《六重不忠》', + '《七重痼病》', + '《八重回归》', + '《九重现实》', + ], + }, + { + group: '角色歌', + items: ['《三重恋爱》', '《泠重乞愿》', '《零重祈愿》'], + }, + { + group: '外篇', + items: ['《自攻自受》', '《妄想 Paranoia》', '《妄想 Reality》', '《十重告别》'], + }, +] + +/** + * 侧边栏章节谱:把应用的每个功能对应到系列的一重上。 + * 坠落之章用 b(降号),上升之章用 #(升号)——这也是原作分章的方式。 + */ +export interface ParanoiaChapterMark { + /** 章节记号 */ + sigil: string + /** 展示用的重数 */ + label: string + /** 曲名 */ + title: string +} + +export const paranoiaNavMarks: Record = { + dashboard: { sigil: '✳', label: '序曲', title: 'NOVA' }, + timers: { sigil: 'b', label: '一重', title: '一重加害' }, + rules: { sigil: 'b', label: '二重', title: '二重变革' }, + accounts: { sigil: 'b', label: '三重', title: '三重爱恋' }, + gateways: { sigil: 'b', label: '四重', title: '四重罪孽' }, + 'gateway-rules': { sigil: 'b', label: '五重', title: '五重空洞' }, + logs: { sigil: '#', label: '六重', title: '六重不忠' }, + settings: { sigil: '#', label: '七重', title: '七重痼病' }, + logout: { sigil: '#', label: '八重', title: '八重回归' }, + paranoia: { sigil: '#', label: '九重', title: '九重现实' }, +} + +export const paranoiaCredits = { + title: '妄想症 Paranoia', + span: '2015.11.28 — 2017.03.17 · 共 475 天', + staff: [ + { role: '作编曲', name: 'DELA' }, + { role: '作词 / 策划', name: '雨狸' }, + { role: '曲绘', name: 'Lune、YAL、雨湘雪' }, + { role: 'PV', name: '一折起售、owen-z' }, + { role: '混音', name: 'POiSON' }, + { role: '调校', name: '花儿不哭、Digger、河谐、OQQ、流绪' }, + ], + notice: + '本主题为界面外观层面的同人致敬,非官方合作。「妄想症 Paranoia」系列曲目、曲绘、歌词与设定的著作权均归原作者所有;此处仅作介绍与引用,不作任何商业用途。', +} + +export function chapterByNo(no: number): ParanoiaChapter | undefined { + return paranoiaChapters.find((item) => item.no === no) +} + +/** 按日期轮换一条引文,让「今日签文」每天都不一样 */ +export function todaysQuote(date = new Date()) { + const start = new Date(date.getFullYear(), 0, 0) + const dayOfYear = Math.floor((date.getTime() - start.getTime()) / 86400000) + return paranoiaQuotes[dayOfYear % paranoiaQuotes.length] +} diff --git a/web/frontend/src/layouts/MainLayout.vue b/web/frontend/src/layouts/MainLayout.vue index ae1c056..f6351f7 100644 --- a/web/frontend/src/layouts/MainLayout.vue +++ b/web/frontend/src/layouts/MainLayout.vue @@ -15,10 +15,14 @@ import { Moon, Sunny, Fold, + Reading, } from '@element-plus/icons-vue' import { useUserStore } from '@/stores/user' import { useTheme } from '@/composables/useTheme' import LogoMark from '@/components/LogoMark.vue' +import ThemePicker from '@/components/ThemePicker.vue' +import ParanoiaBackdrop from '@/components/ParanoiaBackdrop.vue' +import { paranoiaNavMarks } from '@/data/paranoia' const route = useRoute() const router = useRouter() @@ -35,7 +39,7 @@ watch( }, ) -const menus = [ +const baseMenus = [ { path: '/dashboard', title: '仪表盘', icon: Odometer }, { path: '/timers', title: '定时器', icon: TimerIcon }, { path: '/rules', title: '规则', icon: Operation }, @@ -49,7 +53,33 @@ const menus = [ const activePath = computed(() => `/${route.path.split('/')[1] ?? ''}`) // 主题状态是全局单例,初始化在 main.ts 里完成 -const { isDark, toggleTheme } = useTheme() +const { isDark, isParanoia, toggleTheme } = useTheme() + +/** 妄想症皮肤下多出一个「妄想症」展柜,其余菜单不变 */ +const menus = computed(() => + isParanoia.value + ? [...baseMenus, { path: '/paranoia', title: '妄想症', icon: Reading }] + : baseMenus, +) + +/** 每个功能对应的章节记号,只在妄想症皮肤下显示 */ +function markOf(path: string) { + return paranoiaNavMarks[path.slice(1)] +} + +/** 当前所处章节,用于顶栏与侧边栏落款 */ +const currentMark = computed(() => markOf(activePath.value) ?? paranoiaNavMarks.dashboard) + +// 展柜属于皮肤的一部分:皮肤关掉(含直接输入地址进入)就退回仪表盘 +watch( + isParanoia, + (on) => { + if (!on && route.path.startsWith('/paranoia')) { + router.push('/dashboard') + } + }, + { immediate: true }, +) async function handleLogout() { await userStore.logout() @@ -59,7 +89,10 @@ async function handleLogout() { @@ -399,8 +447,116 @@ async function handleLogout() { -webkit-overflow-scrolling: touch; } +/* ---------- 妄想症皮肤:侧边栏变成卷宗封皮 ---------- */ + +/* 背景层待在内容之下 */ +.layout.skin-paranoia { + position: relative; + isolation: isolate; +} + +.layout.skin-paranoia > .content { + position: relative; + z-index: 1; +} + +.layout.skin-paranoia .aside { + background: + linear-gradient(180deg, rgb(224 36 94 / 0.18) 0%, transparent 36%), + linear-gradient(180deg, var(--gb-ink) 0%, var(--gb-ink-soft) 100%); + border-right: 1px solid rgb(224 36 94 / 0.18); +} + +.layout.skin-paranoia .logo-mark { + border-radius: 6px; + background: linear-gradient(135deg, #e0245e 0%, #7a0f2c 100%); + color: #fff; + box-shadow: 0 4px 16px rgb(224 36 94 / 0.45); +} + +.layout.skin-paranoia .user-avatar { + background: linear-gradient(135deg, #e0245e 0%, #7a2ea0 100%); + color: #fff; +} + +.layout.skin-paranoia .logo-text { + font-family: var(--pa-serif); + letter-spacing: 0.04em; +} + +.layout.skin-paranoia .logo-sub { + color: rgb(242 86 138 / 0.75); + font-family: var(--pa-mono); + font-size: 10px; + letter-spacing: 0.14em; +} + +.layout.skin-paranoia .nav-item { + border: 1px solid transparent; + border-radius: 4px; +} + +.layout.skin-paranoia .nav-item:hover { + background: rgb(224 36 94 / 0.1); + border-color: rgb(224 36 94 / 0.2); +} + +.layout.skin-paranoia .nav-item.active { + background: linear-gradient(135deg, #e0245e 0%, #8f1230 100%); + color: #fff; + box-shadow: 0 4px 16px rgb(224 36 94 / 0.4); +} + +/* 每一项右侧标出它对应的「重数」 */ +.layout.skin-paranoia .nav-mark { + margin-left: auto; + font-size: 10px; + letter-spacing: 0.06em; + opacity: 0.45; +} + +.layout.skin-paranoia .nav-item.active .nav-mark { + opacity: 0.85; +} + +.layout.skin-paranoia .footer-chapter { + display: flex; + flex-direction: column; + gap: 3px; + padding: 0 0 10px; + margin-bottom: 10px; + border-bottom: 1px solid rgb(224 36 94 / 0.2); +} + +.layout.skin-paranoia .footer-chapter__sigil { + font-size: 10px; + letter-spacing: 0.16em; + color: rgb(242 86 138 / 0.85); +} + +.layout.skin-paranoia .footer-chapter__title { + font-family: var(--pa-serif); + font-size: 15px; + font-weight: 700; + letter-spacing: 0.1em; + color: rgb(255 255 255 / 0.88); +} + +.layout.skin-paranoia .header-mark { + margin-left: 4px; + font-size: 10px; +} + /* ---------- 移动端 ---------- */ +@media (min-width: 769px) { + /* 桌面端侧边栏常驻,压到背景层上面 */ + .layout.skin-paranoia > .aside { + position: relative; + z-index: 1; + } +} + @media (max-width: 768px) { /* 侧边栏脱离文档流,变成从左侧滑出的抽屉 */ .aside { @@ -451,5 +607,10 @@ async function handleLogout() { .main { padding: 16px 14px 32px; } + + /* 窄屏标题优先,章节记号召回抽屉里看 */ + .layout.skin-paranoia .header-mark { + display: none; + } } diff --git a/web/frontend/src/main.ts b/web/frontend/src/main.ts index ca7710b..a9f5a60 100644 --- a/web/frontend/src/main.ts +++ b/web/frontend/src/main.ts @@ -9,6 +9,8 @@ import App from './App.vue' import router from './router' import { initTheme } from './composables/useTheme' import './assets/main.css' +// 皮肤样式放在最后,才能压过 Element Plus 与基础样式里的变量 +import './assets/theme-paranoia.css' // 挂载前先定好主题,避免登录页闪一下白底 initTheme() diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts index 7cfa814..3db92f4 100644 --- a/web/frontend/src/router/index.ts +++ b/web/frontend/src/router/index.ts @@ -59,6 +59,12 @@ const router = createRouter({ component: () => import('@/views/SettingsView.vue'), meta: { title: '设置' }, }, + { + path: 'paranoia', + name: 'paranoia', + component: () => import('@/views/ParanoiaView.vue'), + meta: { title: '妄想症' }, + }, ], }, { path: '/:pathMatch(.*)*', redirect: '/dashboard' }, diff --git a/web/frontend/src/views/DashboardView.vue b/web/frontend/src/views/DashboardView.vue index 1e65b41..0f6be18 100644 --- a/web/frontend/src/views/DashboardView.vue +++ b/web/frontend/src/views/DashboardView.vue @@ -17,9 +17,16 @@ import type { DashboardOverview } from '@/api/types' import { formatDateTime, formatDuration } from '@/utils/format' import { useIsMobile } from '@/composables/useBreakpoint' import CountUp from '@/components/CountUp.vue' +import { useTheme } from '@/composables/useTheme' +import { paranoiaNavMarks, todaysQuote } from '@/data/paranoia' const isMobile = useIsMobile() +// 妄想症皮肤下,仪表盘顶上多一条「今日签文」 +const { isParanoia } = useTheme() +const dailyQuote = todaysQuote() +const todayMark = paranoiaNavMarks.dashboard + const router = useRouter() const overview = ref(null) @@ -62,56 +69,66 @@ const ringTextColor = computed(() => { return 'var(--gb-primary-deep)' }) -const stats = computed(() => [ - { - label: '定时器', - sub: `已启用 ${overview.value?.enabled_timers ?? 0} 个`, - value: overview.value?.timer_count, - icon: TimerIcon, - color: '#66ccff', - bg: 'rgb(102 204 255 / 0.14)', - to: '/timers', - }, - { - label: '规则', - sub: `定时器规则 ${overview.value?.rule_count ?? 0} 条`, - value: overview.value?.rule_count, - icon: Operation, - color: '#6366f1', - bg: 'rgb(99 102 241 / 0.12)', - to: '/rules', - }, - { - label: '消息网关', - sub: `网关规则 ${overview.value?.gateway_rule_count ?? 0} 条`, - value: overview.value?.gateway_count, - icon: Connection, - color: '#0ea5e9', - bg: 'rgb(14 165 233 / 0.12)', - to: '/gateways', - }, - { - label: '账号', - sub: '第三方凭据', - value: overview.value?.account_count, - icon: User, - color: '#f59e0b', - bg: 'rgb(245 158 11 / 0.12)', - to: '/accounts', - }, - { - label: '已触发', - sub: '规则已执行', - value: overview.value?.triggered_count, - icon: Warning, - color: (overview.value?.triggered_count ?? 0) > 0 ? '#ef4444' : '#94a3b8', - bg: - (overview.value?.triggered_count ?? 0) > 0 +/** + * 统计卡配色:默认皮肤沿用品牌蓝,妄想症皮肤换成三位主角的主题色 + * (泠珞蓝 / 颜语青与黄 / 零羽石蒜红)。 + */ +const stats = computed(() => { + const pa = isParanoia.value + const triggered = (overview.value?.triggered_count ?? 0) > 0 + + return [ + { + label: '定时器', + sub: `已启用 ${overview.value?.enabled_timers ?? 0} 个`, + value: overview.value?.timer_count, + icon: TimerIcon, + color: pa ? '#e0245e' : '#66ccff', + bg: pa ? 'rgb(224 36 94 / 0.16)' : 'rgb(102 204 255 / 0.14)', + to: '/timers', + }, + { + label: '规则', + sub: `定时器规则 ${overview.value?.rule_count ?? 0} 条`, + value: overview.value?.rule_count, + icon: Operation, + color: pa ? '#b98cff' : '#6366f1', + bg: pa ? 'rgb(185 140 255 / 0.14)' : 'rgb(99 102 241 / 0.12)', + to: '/rules', + }, + { + label: '消息网关', + sub: `网关规则 ${overview.value?.gateway_rule_count ?? 0} 条`, + value: overview.value?.gateway_count, + icon: Connection, + color: pa ? '#7ee8e0' : '#0ea5e9', + bg: pa ? 'rgb(126 232 224 / 0.14)' : 'rgb(14 165 233 / 0.12)', + to: '/gateways', + }, + { + label: '账号', + sub: '第三方凭据', + value: overview.value?.account_count, + icon: User, + color: pa ? '#f2d16b' : '#f59e0b', + bg: pa ? 'rgb(242 209 107 / 0.14)' : 'rgb(245 158 11 / 0.12)', + to: '/accounts', + }, + { + label: '已触发', + sub: '规则已执行', + value: overview.value?.triggered_count, + icon: Warning, + color: triggered ? '#ef4444' : pa ? '#8a7880' : '#94a3b8', + bg: triggered ? 'rgb(239 68 68 / 0.12)' - : 'rgb(148 163 184 / 0.12)', - to: '/logs', - }, -]) + : pa + ? 'rgb(138 120 128 / 0.14)' + : 'rgb(148 163 184 / 0.12)', + to: '/logs', + }, + ] +}) async function refresh() { loading.value = true @@ -165,6 +182,21 @@ onUnmounted(() => { + +
+
+ {{ todayMark.sigil }} + {{ todayMark.label }} +
+
+

{{ dailyQuote.text }}

+ {{ dailyQuote.from }} +
+ +
+ { grid-template-columns: 1fr; } } +/* ---------- 妄想症皮肤:今日签文 ---------- */ + +.pa-daily { + display: flex; + align-items: center; + gap: 18px; + padding: 14px 18px; + margin-bottom: 20px; + border: 1px solid var(--pa-line); + border-left: 3px solid var(--gb-primary); + border-radius: 5px; + background: var(--gb-card); +} + +.pa-daily__mark { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + flex: none; + padding-right: 16px; + border-right: 1px solid var(--pa-line); +} + +.pa-daily__sigil { + font-size: 20px; + font-weight: 700; + line-height: 1; + color: var(--gb-primary); +} + +.pa-daily__label { + font-size: 10px; + letter-spacing: 0.14em; + color: var(--el-text-color-secondary); +} + +.pa-daily__body { + flex: 1; + min-width: 0; +} + +.pa-daily__text { + margin: 0 0 4px; + font-family: var(--pa-serif); + font-size: 14px; + line-height: 1.8; + letter-spacing: 0.02em; + color: var(--el-text-color-primary); +} + +.pa-daily__from { + font-size: 11px; + letter-spacing: 0.1em; + color: var(--el-text-color-secondary); +} + +.pa-daily__more { + flex: none; + padding: 6px 12px; + font-family: var(--pa-mono); + font-size: 11px; + letter-spacing: 0.1em; + color: var(--gb-primary); + border: 1px solid var(--pa-line); + border-radius: 3px; + background: transparent; + cursor: pointer; + transition: + border-color 0.2s var(--gb-ease), + background 0.2s var(--gb-ease); +} + +.pa-daily__more:hover { + border-color: var(--pa-line-strong); + background: var(--el-fill-color-light); +} + +@media (max-width: 768px) { + .pa-daily { + flex-wrap: wrap; + gap: 12px; + padding: 14px; + } + + .pa-daily__mark { + flex-direction: row; + gap: 6px; + padding-right: 12px; + } + + .pa-daily__text { + font-size: 13px; + } + + .pa-daily__more { + width: 100%; + } +} diff --git a/web/frontend/src/views/LoginView.vue b/web/frontend/src/views/LoginView.vue index 8e0db67..15002f1 100644 --- a/web/frontend/src/views/LoginView.vue +++ b/web/frontend/src/views/LoginView.vue @@ -1,5 +1,5 @@