From 0245f9f6b6d0a5a781e1ef3573394eb0dead6466 Mon Sep 17 00:00:00 2001 From: oxygennine Date: Thu, 3 Sep 2026 23:59:04 +0800 Subject: [PATCH 1/8] feat: New header style Updated the color scheme of some pages, migrating from gray/indigo to zinc/violet (not fully complete) feat: Added a script to maintain login state in the development environment --- components/Header.js | 70 ++++++++++++------- components/Header.module.css | 55 +++++++++++++++ components/Layout.js | 2 +- next.config.js | 7 +- pages/api/dev/login.js | 97 ++++++++++++++++++++++++++ pages/register.js | 14 ++-- pages/staff-panel.js | 36 +++++----- pages/tools.js | 8 +-- pages/tools/wikidot-register.js | 4 +- public/img/wikit-font-black.svg | 5 ++ public/img/wikit-logo-black.svg | 1 + public/img/wikit-logo-white.svg | 1 + scripts/dev-seed.js | 120 ++++++++++++++++++++++++++++++++ styles/globals.css | 15 ++++ tailwind.config.js | 18 ++++- 15 files changed, 394 insertions(+), 59 deletions(-) create mode 100644 components/Header.module.css create mode 100644 pages/api/dev/login.js create mode 100644 public/img/wikit-font-black.svg create mode 100644 public/img/wikit-logo-black.svg create mode 100644 public/img/wikit-logo-white.svg create mode 100644 scripts/dev-seed.js diff --git a/components/Header.js b/components/Header.js index 9845ea4..e2c7484 100644 --- a/components/Header.js +++ b/components/Header.js @@ -1,22 +1,46 @@ import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import styles from './Header.module.css'; const config = require('../wikitdb.config.js'); // 高清矢量 Logo 组件 const HighDefLogoSVG = ({ className }) => ( Logo { e.target.src = '/img/logo.png'; }} // 降级处理 + onError={(e) => { e.target.src = '/img/logo.svg'; }} // 降级处理 /> ); +// 顶栏导航项。子路由(如 /tools/gacha)同样高亮父级入口。 +// tone 用于个别需要差异化文字色的入口(如职员面板)。 +const NAV_ITEMS = [ + { href: '/pages', label: '页面' }, + { href: '/authors', label: '作者' }, + { href: '/tools', label: '工具' }, +]; +const NAV_ITEMS_TAIL = [ + { href: '/forums', label: '论坛' }, + { href: '/about', label: '关于' }, +]; +const STAFF_ITEM = { href: '/staff-panel', label: '职员面板', tone: 'text-emerald-600 dark:text-emerald-500' }; +const DEFAULT_TONE = 'text-zinc-600 dark:text-zinc-300'; + const Header = () => { + const router = useRouter(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [username, setUsername] = useState(null); const [isStaff, setIsStaff] = useState(false); const [broadcastMsg, setBroadcastMsg] = useState(''); + const isChosen = (href) => + router.pathname === href || router.pathname.startsWith(href + '/'); + + const navItems = isStaff + ? [...NAV_ITEMS, STAFF_ITEM, ...NAV_ITEMS_TAIL] + : [...NAV_ITEMS, ...NAV_ITEMS_TAIL]; + useEffect(() => { const storedUsername = localStorage.getItem('username'); if (storedUsername) { @@ -65,7 +89,7 @@ const Header = () => { )} -
+
@@ -87,33 +111,29 @@ const Header = () => { )}
-
+
- {config.SITE_NAME} + + + + + + + - diff --git a/components/Header.module.css b/components/Header.module.css new file mode 100644 index 0000000..a3f93e7 --- /dev/null +++ b/components/Header.module.css @@ -0,0 +1,55 @@ +/* + * Header 选项卡按钮 —— 伪元素背景渐变 + * + * 职责边界:Tailwind 负责颜色 / 间距 / 布局,这里只写 Tailwind 表达不了的部分 + * (伪元素、渐变、多属性 transition、prefers-reduced-motion)。 + * 两者不重叠,避免同优先级下 CSS 注入顺序不确定导致的样式漂移。 + */ + +.headerButton { + position: relative; + text-indent: 0.15rem; /* 抵消 letter-spacing 误差 */ +} + +.headerButton::before { + content: ''; + position: absolute; + inset: 0; + display: block; + pointer-events: none; + /* header 自身 z-40 且 sticky(建立层叠上下文),41 足以压住 header::after 的分割线 */ + z-index: 41; + + opacity: 0; + /* 渐变画布是元素高度的两倍,靠 background-position 上下位移做「升起」 */ + background-image: linear-gradient(0deg, rgb(196 181 253 / 0.25) 0%, transparent 55%); + background-size: 100% 200%; + /* 起始:画布贴顶,可见的是透明的上半段 */ + background-position: 0 0; + border-bottom: 1.5px solid rgb(139 92 246); + + transition: opacity 250ms ease, background-position 350ms ease; +} + +/* 终态:画布下移,底部 violet 渐变进入视野 */ +.headerButton:hover::before, +.headerButton.chosen::before { + opacity: 1; + background-position: 0 100%; +} + +/* + * 亮色底预留:violet-300 在 zinc-100 上几乎不可见,换成更深的 violet-400 / violet-600。 + * 注意:_document.js 目前把 html className="dark" 写死了, + * 在支持亮色切换之前这一块不会生效。 + */ +:global(html:not(.dark)) .headerButton::before { + background-image: linear-gradient(0deg, rgb(167 139 250 / 0.4) 0%, transparent 75%); + border-bottom-color: rgb(124 58 237); +} + +@media (prefers-reduced-motion: reduce) { + .headerButton::before { + transition: none; + } +} diff --git a/components/Layout.js b/components/Layout.js index a37226a..f3be4e3 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -5,7 +5,7 @@ import Footer from './Footer'; const Layout = ({ children }) => { return ( -
+
{children} diff --git a/next.config.js b/next.config.js index 7d3edf6..3f3336d 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,11 @@ // 允许嵌入的跨域 iframe(删除倒计时器等) const ALLOWED_FRAME_DOMAINS = ['https://timer.backroomswiki.cn']; +// Next.js dev 模式(webpack/HMR runtime)内部使用 eval(), +// CSP 若不放行 'unsafe-eval' 会导致客户端渲染崩溃白屏。 +// 仅在开发环境放宽,生产构建保持严格 CSP。 +const isDev = process.env.NODE_ENV !== 'production'; + const nextConfig = { async rewrites() { return [ @@ -38,7 +43,7 @@ const nextConfig = { { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, - { key: 'Content-Security-Policy', value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://wikit.unitreaty.org https://www.wikidot.com; frame-src 'self' ${ALLOWED_FRAME_DOMAINS.join(' ')}; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'` }, + { key: 'Content-Security-Policy', value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'${isDev ? " 'unsafe-eval'" : ''}; worker-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://wikit.unitreaty.org https://www.wikidot.com; frame-src 'self' ${ALLOWED_FRAME_DOMAINS.join(' ')}; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'` }, ], }, ]; diff --git a/pages/api/dev/login.js b/pages/api/dev/login.js new file mode 100644 index 0000000..3b74918 --- /dev/null +++ b/pages/api/dev/login.js @@ -0,0 +1,97 @@ +import prisma from '../../../lib/prisma'; +import { signToken, serializeAuthCookie } from '../../../utils/auth'; + +/** + * 开发环境身份切换(生产环境返回 404,避免成为后门) + * + * /api/dev/login?as=staff 以职员身份登录并跳转首页 + * /api/dev/login?as=admin&next=/admin 以管理员身份登录并跳转 /admin + * /api/dev/login?as=logout 清除登录态 + * + * 依赖 scripts/dev-seed.js 先造好账号。 + */ + +const ROLES = { + user: 'dev_user', + staff: 'dev_staff', + admin: 'dev_admin', +}; + +export default async function handler(req, res) { + if (process.env.NODE_ENV === 'production') { + return res.status(404).json({ error: 'Not found' }); + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { as = 'user', next = '/' } = req.query; + const target = safeNext(next); + + // 登出:清 httpOnly cookie,同时让页面脚本清掉 localStorage 里的用户名 + if (as === 'logout') { + res.setHeader('Set-Cookie', serializeAuthCookie('', { maxAge: 0 })); + return sendBootstrap(res, '', target); + } + + const username = ROLES[as]; + if (!username) { + return res.status(400).json({ + error: `未知身份「${as}」,可用:${Object.keys(ROLES).join(' / ')} / logout`, + }); + } + + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) { + return res.status(404).json({ + error: `账号 ${username} 不存在,先运行:node scripts/dev-seed.js`, + }); + } + + const token = signToken({ username: user.username, uid: user.id }); + res.setHeader('Set-Cookie', serializeAuthCookie(token)); + return sendBootstrap(res, user.username, target); +} + +/** + * 返回一个落地页而不是 302。 + * + * Header 右上角的用户名读的是 localStorage,而权限走的是 httpOnly cookie + * ——两个来源不一致,只设 cookie 会出现「职员面板入口在、但右上角仍显示登录」的割裂状态。 + * 所以这里顺带把 username 写进 localStorage,让切换后的状态跟真实登录一致。 + */ +function sendBootstrap(res, username, target) { + const html = ` +dev login + +

身份已切换为 ${escapeHtml(username || '未登录')},正在跳转…

+ +`; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.status(200).send(html); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); +} + +/** 只接受站内绝对路径,挡掉协议相对 URL(//evil.com)这类开放重定向 */ +function safeNext(next) { + if (typeof next !== 'string') return '/'; + if (!next.startsWith('/') || next.startsWith('//')) return '/'; + return next; +} diff --git a/pages/register.js b/pages/register.js index dd51a5e..2ccfbac 100644 --- a/pages/register.js +++ b/pages/register.js @@ -156,8 +156,8 @@ export default function Register() { } }; - return ( -
+ return ( +
注册 - {config.SITE_NAME} @@ -217,24 +217,24 @@ export default function Register() {
- + setEmail(e.target.value)} - className="w-full bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl px-4 py-3 text-gray-900 dark:text-white focus:outline-none focus:border-indigo-500 transition-all shadow-inner" + className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl px-4 py-3 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-indigo-500 transition-all shadow-inner" placeholder="用于接收通知的邮箱" />
- +
setCode(e.target.value)} - className="flex-1 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl px-4 py-3 text-gray-900 dark:text-white focus:outline-none focus:border-indigo-500 transition-all shadow-inner" + className="flex-1 bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:outline-none focus:border-indigo-500 transition-all shadow-inner" placeholder="6位验证码" maxLength="6" /> @@ -244,7 +244,7 @@ export default function Register() { disabled={countdown > 0} className={`px-4 py-3 rounded-xl font-bold text-xs uppercase tracking-widest transition-all whitespace-nowrap shadow-sm ${ countdown > 0 - ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed border border-gray-200 dark:border-gray-700' + ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed border border-zinc-200 dark:border-zinc-700' : 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800/30 hover:bg-indigo-200 dark:hover:bg-indigo-900/50' }`} > diff --git a/pages/staff-panel.js b/pages/staff-panel.js index a23f78f..8c83753 100644 --- a/pages/staff-panel.js +++ b/pages/staff-panel.js @@ -198,20 +198,20 @@ export default function StaffPanel() { ))}
-
+
{loading ? ( -
加载中...
+
加载中...
) : posts.length === 0 ? ( -
- +
+ 当前筛选下暂无审核单
) : (
- - +
+ - + @@ -226,15 +226,15 @@ export default function StaffPanel() { const usable = postBots(post.site); return ( - - + + - - - + + + {expanded[post.id] && ( - +
单号单号 站点 / 页面 标题 提交人
#{post.id}
#{post.id} -
{siteName(post.site)}
-
/{post.page}
+
{siteName(post.site)}
+
/{post.page}
{post.title || '-'}{post.username}{new Date(post.createdAt).toLocaleString()}{post.title || '-'}{post.username}{new Date(post.createdAt).toLocaleString()} {st.label} {post.botLabel &&
{post.botLabel}
} @@ -267,7 +267,7 @@ export default function StaffPanel() {
@@ -296,12 +296,12 @@ export default function StaffPanel() {
- 拒绝备注: + 拒绝备注: setRejectNote(p => ({ ...p, [post.id]: e.target.value }))} placeholder="选填,拒绝原因(仅拒绝时需要)" - className="bg-gray-900 border border-gray-600 text-white text-sm rounded-lg p-1.5 flex-1 min-w-[220px]" /> + className="bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm rounded-lg p-1.5 flex-1 min-w-[220px]" />
-
+
页面源码预览
{post.source}
diff --git a/pages/tools.js b/pages/tools.js index adc9382..21af082 100644 --- a/pages/tools.js +++ b/pages/tools.js @@ -20,9 +20,9 @@ export default function Tools() {
工具箱 - {config.SITE_NAME}
-
-

工具箱

-

WikitDB 的各项扩展功能与实验性应用。

+
+

工具箱

+

WikitDB 的各项扩展功能与实验性应用。

@@ -47,7 +47,7 @@ export default function Tools() {
diff --git a/pages/tools/wikidot-register.js b/pages/tools/wikidot-register.js index bddca79..60ca0b2 100644 --- a/pages/tools/wikidot-register.js +++ b/pages/tools/wikidot-register.js @@ -86,8 +86,8 @@ export default function WikidotRegister() { <> 代注册 Wikidot 账号 - {config.SITE_NAME}
-
- +
+ 返回

代注册 Wikidot 账号

diff --git a/public/img/wikit-font-black.svg b/public/img/wikit-font-black.svg new file mode 100644 index 0000000..4939c16 --- /dev/null +++ b/public/img/wikit-font-black.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/img/wikit-logo-black.svg b/public/img/wikit-logo-black.svg new file mode 100644 index 0000000..a6ce58c --- /dev/null +++ b/public/img/wikit-logo-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/wikit-logo-white.svg b/public/img/wikit-logo-white.svg new file mode 100644 index 0000000..4aafc87 --- /dev/null +++ b/public/img/wikit-logo-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/dev-seed.js b/scripts/dev-seed.js new file mode 100644 index 0000000..c750573 --- /dev/null +++ b/scripts/dev-seed.js @@ -0,0 +1,120 @@ +/** + * 开发环境身份调试 —— 种子数据 + * + * 本地注册流程走不通(check 步骤要去外部 Wikidot 站点做活体验证, + * submit 步骤依赖 SMTP 邮件验证码),所以直接往库里写账号。 + * + * 用法:node scripts/dev-seed.js + * + * 创建三个固定身份: + * dev_user 普通成员(无特权) + * dev_staff 职员(staffSites = ["brcn","x"],只能审这两个站) + * dev_admin 管理员(全部站点 + admin 面板) + * + * 同时造若干 proxy_posts 审核单,覆盖全部状态,供职员面板 UI 调试。 + * 重复执行是幂等的(upsert + 按 username/page 去重)。 + */ +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); + +const prisma = new PrismaClient(); + +const PASSWORD = 'wikitdb-dev-2026'; + +const ACCOUNTS = [ + { username: 'dev_user', isStaff: false, isAdmin: false, staffSites: null }, + { username: 'dev_staff', isStaff: true, isAdmin: false, staffSites: JSON.stringify(['brcn', 'x']) }, + { username: 'dev_admin', isStaff: false, isAdmin: true, staffSites: null }, +]; + +const SAMPLE_SOURCE = `[[div class="test-block"]] +这是一条用于 UI 调试的示例代发内容。 + ++ 列表项一 ++ 列表项二 +[[/div]]`; + +const SAMPLE_POSTS = [ + { site: 'brcn', siteName: 'The Bsckrooms中文维基', page: 'dev-sample-001', title: '示例:层级错乱的实体档案', status: 'pending', comments: '麻烦职员帮忙代发,谢谢' }, + { site: 'brcn', siteName: 'The Bsckrooms中文维基', page: 'dev-sample-002', title: '示例:待补充图片的文档', status: 'approved', comments: null, reviewNote: '内容没问题,已通过' }, + { site: 'x', siteName: 'The Backrooms X层群', page: 'dev-sample-003', title: '示例:X层群新条目', status: 'sent', comments: '急,今天想发出去' }, + { site: 'x', siteName: 'The Backrooms X层群', page: 'dev-sample-004', title: '示例:格式不合规草稿', status: 'rejected', comments: null, reviewNote: '标题格式不符合站点规范,请修改后重新提交' }, + { site: 'brcn', siteName: 'The Bsckrooms中文维基', page: 'dev-sample-005', title: '示例:发送失败的条目', status: 'failed', comments: null, sendResult: 'Wikidot 返回 503,稍后重试' }, + { site: 'dfc', siteName: '深林文学部', page: 'dev-sample-006', title: '示例:职员权限外的站点', status: 'pending', comments: '这条属于 dev_staff 权限之外,仅管理员可见' }, +]; + +async function main() { + const passwordHash = await bcrypt.hash(PASSWORD, 10); + const ids = {}; + + for (const acc of ACCOUNTS) { + const user = await prisma.user.upsert({ + where: { username: acc.username }, + update: { + password: passwordHash, + isStaff: acc.isStaff, + isAdmin: acc.isAdmin, + staffSites: acc.staffSites, + status: 'active', + }, + create: { + username: acc.username, + password: passwordHash, + isStaff: acc.isStaff, + isAdmin: acc.isAdmin, + staffSites: acc.staffSites, + balance: 10000, + status: 'active', + }, + }); + ids[acc.username] = user.id; + } + + // 审核单归属 dev_user(提交人),这样职员面板里能看到"别人提交的" + const submitterId = ids.dev_user; + let createdPosts = 0; + + for (const p of SAMPLE_POSTS) { + const existing = await prisma.proxyPost.findFirst({ + where: { site: p.site, page: p.page }, + }); + if (existing) continue; + + await prisma.proxyPost.create({ + data: { + userId: submitterId, + username: 'dev_user', + site: p.site, + siteName: p.siteName, + page: p.page, + title: p.title, + source: SAMPLE_SOURCE, + comments: p.comments, + status: p.status, + reviewNote: p.reviewNote || null, + reviewedBy: p.reviewNote ? 'dev_staff' : null, + reviewedAt: p.reviewNote ? new Date() : null, + sendResult: p.sendResult || null, + sentAt: p.status === 'sent' ? new Date() : null, + }, + }); + createdPosts++; + } + + const total = await prisma.proxyPost.count(); + + console.log('\n[dev-seed] 账号就绪(统一密码:%s)', PASSWORD); + for (const acc of ACCOUNTS) { + const role = acc.isAdmin ? '管理员' : acc.isStaff ? `职员 (${acc.staffSites})` : '普通成员'; + console.log(' - %s %s', acc.username.padEnd(12), role); + } + console.log('[dev-seed] 本次新增审核单 %d 条,库中合计 %d 条', createdPosts, total); + console.log('[dev-seed] 现在访问 http://localhost:3000/api/dev/login?as=staff 即可切换身份\n'); +} + +main() + .catch((e) => { + console.error('[dev-seed] 失败:', e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/styles/globals.css b/styles/globals.css index 2afb4b1..7d13659 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -6,3 +6,18 @@ html.dark { color-scheme: dark; background-color: #111827; } + +:where(:root) { + --w-primary: oklch(60.6% 0.25 292.717); + --w-primary-50: oklch(96.9% 0.016 293.756); + --w-primary-100: oklch(94.3% 0.029 294.588); + --w-primary-200: oklch(89.4% 0.057 293.283); + --w-primary-300: oklch(81.1% 0.111 293.571); + --w-primary-400: oklch(70.2% 0.183 293.541); + --w-primary-500: oklch(60.6% 0.25 292.717); + --w-primary-600: oklch(54.1% 0.281 293.009); + --w-primary-700: oklch(49.1% 0.27 292.581); + --w-primary-800: oklch(43.2% 0.232 292.759); + --w-primary-900: oklch(38% 0.189 293.745); + --w-primary-950: oklch(28.3% 0.141 291.089); +} diff --git a/tailwind.config.js b/tailwind.config.js index 69f0a7a..e0bfa33 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,7 +7,23 @@ module.exports = { "./styles/**/*.css" ], theme: { - extend: {}, + extend: { + colors: { + primary: { + 50: 'var(--w-primary-50)', + 100: 'var(--w-primary-100)', + 200: 'var(--w-primary-200)', + 300: 'var(--w-primary-300)', + 400: 'var(--w-primary-400)', + 500: 'var(--w-primary-500)', + 600: 'var(--w-primary-600)', + 700: 'var(--w-primary-700)', + 800: 'var(--w-primary-800)', + 900: 'var(--w-primary-900)', + 950: 'var(--w-primary-950)', + } + }, + }, }, plugins: [], } From c897a5d6a86bd0e33aff901dc481f099102e11b7 Mon Sep 17 00:00:00 2001 From: oxygennine Date: Fri, 4 Sep 2026 08:56:30 +0800 Subject: [PATCH 2/8] Header structure sync --- components/Header.js | 35 +++++++++++++++ components/Header.module.css | 5 +-- pages/_document.js | 17 ++++++- styles/globals.css | 86 ++++++++++++++++++++++++++++++++++-- tailwind.config.js | 40 ++++++++++++++++- 5 files changed, 174 insertions(+), 9 deletions(-) diff --git a/components/Header.js b/components/Header.js index 678b735..936792f 100644 --- a/components/Header.js +++ b/components/Header.js @@ -34,6 +34,8 @@ const Header = () => { const [username, setUsername] = useState(null); const [isStaff, setIsStaff] = useState(false); const [broadcastMsg, setBroadcastMsg] = useState(''); + // null = 尚未挂载(服务端/首帧),挂载后以 实际类名为准 + const [theme, setTheme] = useState(null); const isChosen = (href) => router.pathname === href || router.pathname.startsWith(href + '/'); @@ -42,6 +44,18 @@ const Header = () => { ? [...NAV_ITEMS, STAFF_ITEM, ...NAV_ITEMS_TAIL] : [...NAV_ITEMS, ...NAV_ITEMS_TAIL]; + // _document.js 的内联脚本已在首屏前应用主题,这里直接读取实际状态 + useEffect(() => { + setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light'); + }, []); + + const toggleTheme = () => { + const next = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + localStorage.setItem('theme', next); + document.documentElement.classList.toggle('dark', next === 'dark'); + }; + useEffect(() => { const storedUsername = localStorage.getItem('username'); if (storedUsername) { @@ -139,7 +153,28 @@ const Header = () => {
+ {/* 移动端主题切换(顶栏右端) */} +
+ +
+
+ {username ? ( <> {username} diff --git a/components/Header.module.css b/components/Header.module.css index a3f93e7..b6b9dc4 100644 --- a/components/Header.module.css +++ b/components/Header.module.css @@ -39,9 +39,8 @@ } /* - * 亮色底预留:violet-300 在 zinc-100 上几乎不可见,换成更深的 violet-400 / violet-600。 - * 注意:_document.js 目前把 html className="dark" 写死了, - * 在支持亮色切换之前这一块不会生效。 + * 亮色底适配:violet-300 在 zinc-100 上几乎不可见,换成更深的 violet-400 / violet-600。 + * 明暗由 Header 的切换按钮控制(_document.js 内联脚本在首屏前应用 localStorage 偏好)。 */ :global(html:not(.dark)) .headerButton::before { background-image: linear-gradient(0deg, rgb(167 139 250 / 0.4) 0%, transparent 75%); diff --git a/pages/_document.js b/pages/_document.js index c04dee3..df7a888 100644 --- a/pages/_document.js +++ b/pages/_document.js @@ -1,16 +1,31 @@ import { Html, Head, Main, NextScript } from 'next/document'; const config = require('../wikitdb.config.js'); +// 首屏绘制前根据 localStorage / 系统偏好确定主题,避免明暗闪烁(FOUC)。 +// 无存储偏好时跟随系统,系统也无偏好时保持暗色(站点历史默认)。 +const themeInitScript = ` +(function () { + try { + var stored = localStorage.getItem('theme'); + var dark = stored + ? stored === 'dark' + : !window.matchMedia('(prefers-color-scheme: light)').matches; + document.documentElement.classList.toggle('dark', dark); + } catch (e) {} +})(); +`; + export default function Document() { return ( +