diff --git a/.env.example b/.env.example index bd8cb55..0fe4cbf 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,12 @@ SMTP_PASS= WIKIDOT_BOT_USER= WIKIDOT_BOT_PASS= + +# 开发环境身份切换开关(/api/dev/login) +# 该接口能直接签发任意角色的 JWT,默认关闭。 +# 本地调试时设为 1,并先执行 node scripts/dev-seed.js 造账号。 +# 生产环境即使误设也不会生效(NODE_ENV=production 时无条件 404)。 +ENABLE_DEV_LOGIN= + +# dev-seed.js 写入的测试账号口令(可选,未设置时用内置默认值) +DEV_SEED_PASSWORD= diff --git a/README.md b/README.md index b73f474..328a93f 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ WikitDB-Server/ - **白然** — WikitDB LOGO 设计师 - **Kakushi** - Wikit创始人,Wikit API运维 - **UMOU** - 贡献者 +- **OxygenNine** - ui-redesign - 每一位为 Wikidot 社区贡献原创内容的创作者 ## 许可证 diff --git a/components/AuthorActivityChart.js b/components/AuthorActivityChart.js index d599218..568f367 100644 --- a/components/AuthorActivityChart.js +++ b/components/AuthorActivityChart.js @@ -2,6 +2,34 @@ import React, { useEffect, useRef, useState } from 'react'; // 核心修复:直接使用 auto 全自动注册,彻底解决漏引组件导致的致命闪退 import Chart from 'chart.js/auto'; +const isDark = () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'); + +// canvas 无法读取 CSS 变量,按当前主题返回具体色值 +const getChartColors = () => { + if (isDark()) { + return { + bar: 'rgba(139, 92, 246, 0.85)', + barBorder: 'rgb(139, 92, 246)', + tooltipBg: 'rgba(24, 24, 27, 0.96)', + tooltipTitle: '#f4f4f5', + tooltipBody: '#a1a1aa', + tooltipBorder: 'rgba(63, 63, 70, 1)', + grid: 'rgba(255, 255, 255, 0.05)', + ticks: '#a1a1aa', + }; + } + return { + bar: 'rgba(139, 92, 246, 0.85)', + barBorder: 'rgb(139, 92, 246)', + tooltipBg: 'rgba(255, 255, 255, 0.96)', + tooltipTitle: '#18181b', + tooltipBody: '#52525b', + tooltipBorder: 'rgba(228, 228, 231, 1)', + grid: 'rgba(0, 0, 0, 0.06)', + ticks: '#52525b', + }; +}; + export default function AuthorActivityChart({ data = [] }) { const canvasRef = useRef(null); const chartInstance = useRef(null); @@ -55,6 +83,8 @@ export default function AuthorActivityChart({ data = [] }) { const ctx = canvasRef.current.getContext('2d'); + const colors = getChartColors(); + chartInstance.current = new Chart(ctx, { type: 'bar', data: { @@ -63,8 +93,8 @@ export default function AuthorActivityChart({ data = [] }) { { label: '发布页面数', data: pagesData, - backgroundColor: 'rgba(99, 102, 241, 0.85)', - borderColor: 'rgb(99, 102, 241)', + backgroundColor: colors.bar, + borderColor: colors.barBorder, borderWidth: 1, barPercentage: 0.9, categoryPercentage: 1.0, @@ -77,10 +107,10 @@ export default function AuthorActivityChart({ data = [] }) { plugins: { legend: { display: false }, tooltip: { - backgroundColor: 'rgba(23,23,23,0.96)', - titleColor: '#fff', - bodyColor: 'rgb(200,200,200)', - borderColor: 'rgba(255,255,255,0.1)', + backgroundColor: colors.tooltipBg, + titleColor: colors.tooltipTitle, + bodyColor: colors.tooltipBody, + borderColor: colors.tooltipBorder, borderWidth: 1, callbacks: { label: function(context) { @@ -98,16 +128,38 @@ export default function AuthorActivityChart({ data = [] }) { scales: { x: { grid: { display: false }, - ticks: { color: 'rgb(110, 118, 129)', maxRotation: 45 } + ticks: { color: colors.ticks, maxRotation: 45 } }, y: { beginAtZero: true, - grid: { color: 'rgba(255, 255, 255, 0.05)' }, - ticks: { color: 'rgb(110, 118, 129)', stepSize: 1 } + grid: { color: colors.grid }, + ticks: { color: colors.ticks, stepSize: 1 } } } } }); + + // 亮暗主题切换时重设 canvas 配色(canvas 不支持 CSS 变量,需 JS 侧感知) + const applyThemeColors = () => { + const chart = chartInstance.current; + if (!chart) return; + const c = getChartColors(); + chart.data.datasets[0].backgroundColor = c.bar; + chart.data.datasets[0].borderColor = c.barBorder; + chart.options.plugins.tooltip.backgroundColor = c.tooltipBg; + chart.options.plugins.tooltip.titleColor = c.tooltipTitle; + chart.options.plugins.tooltip.bodyColor = c.tooltipBody; + chart.options.plugins.tooltip.borderColor = c.tooltipBorder; + chart.options.scales.x.ticks.color = c.ticks; + chart.options.scales.y.ticks.color = c.ticks; + chart.options.scales.y.grid.color = c.grid; + chart.update('none'); + }; + + const themeObserver = new MutationObserver(applyThemeColors); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + + return () => themeObserver.disconnect(); } catch (err) { console.error("图表引擎渲染异常:", err); setChartError(err.message); @@ -118,7 +170,7 @@ export default function AuthorActivityChart({ data = [] }) { return (
{chartError && ( -
+
图表渲染失败: {chartError}
)} diff --git a/components/ErrorBoundary.js b/components/ErrorBoundary.js index 311997c..8d84ae0 100644 --- a/components/ErrorBoundary.js +++ b/components/ErrorBoundary.js @@ -21,14 +21,14 @@ export default class ErrorBoundary extends React.Component { if (this.state.hasError) { return (
-
+
-

页面渲染出现异常

-

{this.state.message}

+

页面渲染出现异常

+

{this.state.message}

diff --git a/components/Footer.js b/components/Footer.js index b38e646..4c7819a 100644 --- a/components/Footer.js +++ b/components/Footer.js @@ -9,7 +9,7 @@ const Footer = () => { return ( <> -
+
{`© ${copyrightDate} - `}{config.SITE_AUTHOR}
diff --git a/components/Header.js b/components/Header.js index 58c8dfa..f54e143 100644 --- a/components/Header.js +++ b/components/Header.js @@ -1,22 +1,69 @@ import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; import Link from 'next/link'; +import styles from './Header.module.css'; const config = require('../wikitdb.config.js'); -// 高清矢量 Logo 组件 +// 高清矢量 Logo 组件:亮/暗主题各渲染一份,靠 dark: 变体切换显隐(无闪烁、无需 JS) const HighDefLogoSVG = ({ className }) => ( - Logo { e.target.src = '/img/logo.png'; }} // 降级处理 - /> + <> + Logo { e.target.src = '/img/logo.svg'; }} // 降级处理 + /> + { 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-primary-600 dark:text-primary-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(''); + // null = 尚未挂载(服务端/首帧),挂载后以 实际类名为准 + const [theme, setTheme] = useState(null); + + 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]; + + // _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'); @@ -66,14 +113,14 @@ const Header = () => {
)} -
+
-
+
- {config.SITE_NAME} + + + + + + + -
+
- - 页面 - - - 作者 - - - 工具 - - {isStaff && ( - - 职员面板 + {navItems.map((item) => ( + + {item.label} - )} - - 论坛 - - - 关于 - + ))}
+ {/* 移动端主题切换(顶栏右端) */} +
+ +
+
+ {username ? ( <> - {username} + {username} ) : ( <> - 登录 - 注册 + 登录 + 注册 )}
-
-
+
+
- + 页面 {/* ... 其他链接同理 ... */} - + 作者 - + 工具 {isStaff && ( - + 职员面板 )} - + 论坛 - + 关于
-
+
{username ? (
- 当前用户:{username} + 当前用户:{username}
) : (
- + 登录 - + 注册
diff --git a/components/Header.module.css b/components/Header.module.css new file mode 100644 index 0000000..56eaa06 --- /dev/null +++ b/components/Header.module.css @@ -0,0 +1,63 @@ +/* + * 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。 + * 明暗由 Header 的切换按钮控制(_document.js 内联脚本在首屏前应用 localStorage 偏好)。 + */ +:global(html:not(.dark)) .headerButton::before { + background-image: linear-gradient(0deg, rgb(167 139 250 / 0.25) 0%, transparent 50%); + border-bottom-color: rgb(124 58 237); +} + +@media (prefers-reduced-motion: reduce) { + .headerButton::before { + transition: none; + } +} + +@media screen and (max-width: 1000px) { + .headerButton { + text-indent: 0; + letter-spacing: 0; + padding: 0 0.5rem; + margin: 0 !important; + } +} diff --git a/components/HeroAsciiCanvas.js b/components/HeroAsciiCanvas.js new file mode 100644 index 0000000..789b987 --- /dev/null +++ b/components/HeroAsciiCanvas.js @@ -0,0 +1,245 @@ +// components/HeroAsciiCanvas.js +import React, { useEffect, useRef } from 'react'; + +/** + * Hero 算法艺术背景(双层渲染管线): + * + * 1. 粒子层:紫色光尘从右侧生成、向左漂移,附正弦扰动, + * 生命周期内淡入淡出,用 additive 混合叠加出亮度层次; + * 2. ASCII 后处理:粒子被绘制进「一格 = 一像素」的低分辨率离屏画布, + * 逐格读取 alpha 亮度 -> 映射为字符密度 + 紫色色阶, + * 通过预渲染字符图集 drawImage 输出(避免每帧上万次 fillText 光栅化)。 + * + * 性能策略: + * - 输出画布 DPR 上限 1.5(ASCII 本身是像素风,高 DPR 无收益); + * - 固定 ~32fps 节流(颗粒漂移动画对帧率不敏感); + * - 亮度低于阈值的单元格直接跳过,实际 drawImage 次数远小于格子总数; + * - 固定粒子池复用、零 GC 压力;scene getImageData 仅 cols×rows 像素; + * - 滚出视口 / 页面切后台自动停帧;respects prefers-reduced-motion。 + */ + +// 字符密度阶梯:由疏到密(索引随亮度增大) +const GLYPHS = '.,:;-=+*x#%@'; +// 紫色色阶:暗 -> 亮,与亮度区间对应 +const SHADES = [ + 'rgba(91,33,182,0.55)', + 'rgba(124,58,237,0.75)', + 'rgba(139,92,246,0.90)', + 'rgba(167,139,250,1)', + 'rgba(237,233,254,1)' +]; +const LUM_MIN = 0.09; // 低于此亮度的格子不绘制 +const FRAME_MS = 1000 / 32; // ~32fps + +const HeroAsciiCanvas = () => { + const ref = useRef(null); + + useEffect(() => { + const canvas = ref.current; + if (!canvas || !canvas.parentElement) return undefined; + + const ctx = canvas.getContext('2d'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + let raf = 0; + let running = false; + let inView = true; + let W = 0; // 逻辑宽(css px) + let H = 0; // 逻辑高 + let dpr = 1; + let cols = 0; // ASCII 列数 + let rows = 0; // ASCII 行数 + let cellW = 8; // 单元格宽(css px) + let cellH = 13; // 单元格高 + let acw = 0; // 图集单元宽(物理 px) + let ach = 0; // 图集单元高 + let scene = null; // 低分辨率离屏画布(1 cell = 1 px) + let sctx = null; + let atlas = null; // 预渲染字符图集 + let particles = []; + let lastT = 0; + + const rand = (a, b) => a + Math.random() * (b - a); + + // 生成粒子:glow 为少量大颗亮尘,其余为细尘 + const makeParticle = (scatter) => { + const glow = Math.random() < 0.07; + return { + x: scatter ? rand(0, W) : W + rand(4, 90), + y: rand(-24, H + 24), + vx: -rand(26, 78), + r: glow ? rand(3.2, 6) : rand(0.8, 2.2), + glow, + amp: rand(4, 26), // 正弦漂移振幅 + wf: rand(0.0004, 0.0013), // 漂移频率 + ph: rand(0, Math.PI * 2), // 漂移相位 + life: scatter ? rand(0.25, 1) : 1, + decay: rand(0.05, 0.14) // 每秒衰减(决定寿命 7~20s) + }; + }; + + const seedParticles = () => { + const target = Math.round((W * H) / 8200); + const count = Math.max(90, Math.min(420, W < 640 ? Math.round(target * 0.6) : target)); + particles = Array.from({ length: count }, () => makeParticle(true)); + }; + + const buildAtlas = () => { + const n = GLYPHS.length; + const m = SHADES.length; + acw = Math.ceil(cellW * dpr); + ach = Math.ceil(cellH * dpr); + atlas = document.createElement('canvas'); + atlas.width = n * acw; + atlas.height = m * ach; + const a = atlas.getContext('2d'); + a.font = `700 ${Math.round(ach * 0.78)}px ui-monospace, 'Cascadia Mono', Consolas, Menlo, monospace`; + a.textAlign = 'center'; + a.textBaseline = 'middle'; + for (let y = 0; y < m; y++) { + a.fillStyle = SHADES[y]; + for (let x = 0; x < n; x++) { + a.fillText(GLYPHS[x], x * acw + acw / 2, y * ach + ach * 0.54); + } + } + }; + + const resize = () => { + const rect = canvas.parentElement.getBoundingClientRect(); + W = Math.max(320, Math.round(rect.width)); + H = Math.max(240, Math.round(rect.height)); + dpr = Math.min(window.devicePixelRatio || 1, 1.5); + + const small = W < 640; + cellW = small ? 9 : 8; + cellH = small ? 15 : 13; + + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + cols = Math.ceil(W / cellW); + rows = Math.ceil(H / cellH); + scene = document.createElement('canvas'); + scene.width = cols; + scene.height = rows; + sctx = scene.getContext('2d', { willReadFrequently: true }); + + buildAtlas(); + seedParticles(); + }; + + // 单帧:更新粒子 -> 低分辨率场景 -> ASCII 后处理输出 + const step = (t) => { + const dt = Math.min(0.05, (t - lastT) / 1000 || 0.016); + lastT = t; + + // --- 粒子模拟 + 绘制到 1 cell = 1 px 的场景 --- + sctx.clearRect(0, 0, cols, rows); + sctx.globalCompositeOperation = 'lighter'; + sctx.fillStyle = '#fff'; + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + p.life -= p.decay * dt; + p.x += p.vx * dt; + if (p.life <= 0 || p.x < -30) { + particles[i] = makeParticle(false); + continue; + } + const age = 1 - p.life; + // 两端各 ~20% 生命周期做淡入 / 淡出 + const fade = Math.min(1, age * 5) * Math.min(1, p.life * 5); + if (fade <= 0.01) continue; + const yy = p.y + Math.sin(t * p.wf + p.ph) * p.amp; + const size = Math.max(1, (p.r * 2 * (p.glow ? 1.5 : 1)) / cellW); + sctx.globalAlpha = Math.min(1, fade * (p.glow ? 1 : 0.72)); + sctx.fillRect(p.x / cellW, yy / cellH, size, size); + } + sctx.globalAlpha = 1; + sctx.globalCompositeOperation = 'source-over'; + + // --- ASCII 后处理 --- + const data = sctx.getImageData(0, 0, cols, rows).data; + ctx.clearRect(0, 0, W, H); + const n = GLYPHS.length; + const m = SHADES.length; + for (let y = 0; y < rows; y++) { + const dy = y * cellH; + const rowOff = y * cols * 4; + for (let x = 0; x < cols; x++) { + const lum = data[rowOff + x * 4 + 3] / 255; // additive 白色粒子:alpha 即亮度 + if (lum < LUM_MIN) continue; + const g = Math.min(n - 1, Math.floor(lum * n)); + const s = Math.min(m - 1, Math.floor(lum * m)); + ctx.drawImage(atlas, g * acw, s * ach, acw, ach, x * cellW, dy, cellW, cellH); + } + } + }; + + const loop = (t) => { + raf = requestAnimationFrame(loop); + if (t - lastT < FRAME_MS) return; + step(t); + }; + + const start = () => { + if (running || reduced || document.hidden || !inView) return; + running = true; + lastT = performance.now(); + raf = requestAnimationFrame(loop); + }; + + const stop = () => { + running = false; + cancelAnimationFrame(raf); + }; + + resize(); + step(performance.now()); // 先出一帧静态画面,避免挂载初期空白 + + let resizeTimer = 0; + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + resize(); + step(performance.now()); + }, 150); + }; + const ro = new ResizeObserver(onResize); + ro.observe(canvas.parentElement); + + const io = new IntersectionObserver( + (entries) => { + inView = entries[0]?.isIntersecting ?? true; + if (inView) start(); + else stop(); + }, + { threshold: 0 } + ); + io.observe(canvas); + + const onVis = () => { + if (document.hidden) stop(); + else start(); + }; + document.addEventListener('visibilitychange', onVis); + + return () => { + stop(); + clearTimeout(resizeTimer); + ro.disconnect(); + io.disconnect(); + document.removeEventListener('visibilitychange', onVis); + }; + }, []); + + return ( +