feat: 前端迁移 React 18 + Vite 5 脚手架与保真移植的控制器

为什么:index.html 单体 4600 行 ES5 已到维护极限,迁移到 Vite 工程
获得热更新、类型检查与模块化能力。迁移策略是保真优先——业务逻辑
不重写为 React 组件(行为漂移风险),而是逐字移植为
legacy.ts/auth.ts 两个 @ts-nocheck 控制器模块;React 只接管静态
骨架与响应式显隐(tab/modal/drawer/toast),动态列表/播放器/歌词
仍走 id + innerHTML 命令式更新。

关键决策:
- boot 包装:旧脚本原在 body 末尾可直取 DOM,模块化后顶层事件
  绑定会在 import 求值时打在 null 上,全部副作用包进
  __legacyBoot()/__authBoot() 由 App useEffect 骨架落盘后调用
- 轻量 store:单一可变 AppState + notify() + useSyncExternalStore,
  沿用旧版"改 state 后手动通知"心智模型
- modalBus:弹窗栈 + useSyncExternalStore,Escape 走 closeTopmost,
  stats/cookie 弹窗经 __legacyApi 调 legacy 内部专用关闭(清定时器/
  复位视图)
- vite 固定产物文件名(无内容 hash),保证 index.html 字节级稳定,
  golden sha256 契约才跨构建成立
This commit is contained in:
snowy-yang committed 2026-09-27 18:34:49 +08:00
1 parent 0dbbb3ed63
commit 3a089caff2
15 files changed
+11089

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%B5%3C/text%3E%3C/svg%3E"
/>
<title>云村小助手</title>
</head>
<body>
<div id="root"></div>
<script>
window.__errs = []
window.addEventListener('error', function (e) {
window.__errs.push((e.message || String(e)) + ' @ ' + (e.filename || '?') + ':' + e.lineno)
})
window.addEventListener('unhandledrejection', function (e) {
window.__errs.push('PROMISE: ' + ((e.reason && e.reason.message) || String(e.reason)))
})
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1630
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "yunvillage-frontend",
"private": true,
"version": "2.3.21",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.3",
"vite": "^5.4.11"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useRef } from 'react'
import { Skeleton } from './ui/Skeleton'
export default function App() {
const booted = useRef(false)
useEffect(() => {
if (booted.current) return
booted.current = true
// 骨架已提交 DOM 后再启动控制器:旧脚本假定 DOM 就绪(原脚本位于 body 末尾)
import('./controllers/legacy')
.then((m) => {
m.__legacyBoot()
return import('./controllers/auth')
})
.then((a) => a.__authBoot())
.catch((e) => {
console.error('前端启动失败:', e)
;(window.__errs = window.__errs || []).push('BOOT: ' + ((e && (e as Error).message) || String(e)))
})
}, [])
return <Skeleton />
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
/* 旧脚本通过 window.* 全局钩子双向协作(与旧版一致,暂保留该机制)。
* 这里放宽 window 索引类型,避免逐个声明钩子。 */
interface Window {
[key: string]: any
}
+85
View File
@@ -0,0 +1,85 @@
/* 轻量全局 store:保持旧版「单一可变 state 对象 + 显式 notify」的心智模型。
* 组件通过 useAppVersion() 订阅版本号,读取 store 字段渲染;
* 逻辑层照旧直接改 store 字段后调 notify()——与旧版改 state + render*() 等价。 */
import { useSyncExternalStore } from 'react'
import type { AppState } from './types'
import { DEFAULT_SETTINGS } from './types'
export const store: AppState = {
currentSong: null,
lyrics: [],
lyricsTrans: [],
rawLyric: null,
rawTlyric: null,
rawYrc: null,
currentTab: 'search',
commentOffset: 0,
commentTotal: 0,
commentHasMore: false,
commentPage: 1,
commentReply: null,
settings: { ...DEFAULT_SETTINGS, metaFields: { ...DEFAULT_SETTINGS.metaFields }, cookiePrefs: { ...DEFAULT_SETTINGS.cookiePrefs } },
adminAuthed: false,
adminPassword: '',
adminQrTimer: null,
adminUnikey: '',
personal: { authed: false, qrTimer: null, unikey: '', user: null, playlists: [], current: null, cookie: '' },
global: { user: null, playlists: [], current: null, playQueue: [], playQueueIndex: 0 },
parseAccount: 'admin',
levelsOk: [],
lyricOffset: 0,
currentCollection: null,
podcast: { hotLoaded: false, hotCate: null, hotOffset: 0, radio: null, radioId: null, program: null, offset: 0, count: 0, asc: true },
auth: { user: null, checked: false, annSeen: '' },
toplistLoaded: false,
collGen: {},
dark: false,
}
let version = 0
const subs = new Set<() => void>()
export function notify() {
version++
subs.forEach((f) => f())
}
function subscribe(f: () => void) {
subs.add(f)
return () => {
subs.delete(f)
}
}
function getVersion() {
return version
}
/** 订阅全局 store 版本号;组件内配合直接读 store 字段使用。 */
export function useAppVersion(): number {
return useSyncExternalStore(subscribe, getVersion)
}
/* ================= Toast ================= */
export interface ToastItem {
id: number
msg: string
type: string
}
let toastSeq = 0
export const toasts: ToastItem[] = []
export function toast(msg: string, type?: string) {
const t = type || 'info'
const id = ++toastSeq
toasts.push({ id, msg, type: t })
notify()
setTimeout(() => {
const i = toasts.findIndex((x) => x.id === id)
if (i >= 0) {
toasts.splice(i, 1)
notify()
}
}, 2900)
}
+126
View File
@@ -0,0 +1,126 @@
/* 全局应用状态类型定义。
* 与旧版 index.html 的 state 对象一一对应,保证移植行为一致。 */
export interface Settings {
primaryColor: string
themeMode: 'light' | 'dark' | 'system'
ktv: boolean
lyric2line: boolean
progressMemory: boolean
showComments: boolean
smtc: boolean
filenameFormat: string
writeMeta: boolean
metaFields: Record<string, boolean>
zipEnabled: boolean
batchWriteMeta: boolean
batchZip: boolean
lyricFormat: 'lrc' | 'srt'
lyricFont: number
cookiePrefs: { functional: boolean; accepted: boolean }
}
export interface PersonalState {
authed: boolean
qrTimer: ReturnType<typeof setInterval> | null
unikey: string
user: any | null
playlists: any[]
current: { id: string; name: string } | null
cookie: string
}
export interface GlobalState {
user: any | null
playlists: any[]
current: { id: string; name: string } | null
playQueue: any[]
playQueueIndex?: number
}
export interface PodcastState {
hotLoaded: boolean
hotCate: string | null
hotOffset: number
radio: any | null
radioId: string | null
program: any | null
offset: number
count: number
asc: boolean
}
export interface AuthState {
user: any | null
checked: boolean
annSeen: string
}
export interface AppState {
currentSong: any | null
lyrics: any[]
lyricsTrans: any[]
rawLyric: string | null
rawTlyric: string | null
rawYrc: string | null
currentTab: string
commentOffset: number
commentTotal: number
commentHasMore: boolean
commentPage: number
commentReply: { id: string; nickname: string } | null
settings: Settings
adminAuthed: boolean
adminPassword: string
adminQrTimer: ReturnType<typeof setInterval> | null
adminUnikey: string
personal: PersonalState
global: GlobalState
parseAccount: 'admin' | 'user'
levelsOk: string[]
lyricOffset: number
currentCollection: { type: string; id: any; name: string } | null
podcast: PodcastState
auth: AuthState
/* 运行时附加状态(与旧版一致,动态挂在 state 上) */
toplistLoaded: boolean
collGen: Record<string, number>
page_playlist?: number
page_album?: number
page_artist?: number
page_personal?: number
page_global?: number
tracks_playlist?: any[]
tracks_album?: any[]
tracks_artist?: any[]
tracks_personal?: any[]
tracks_global?: any[]
_commentsLoading?: boolean
_commentGen?: number
ktvRunning?: boolean
lastActiveLine?: number
ktvFillIdx?: number
ktvWordEls?: NodeListOf<Element> | null
ktvLineEls?: Element[] | null
_queueNoResume?: boolean
dark?: boolean
}
export const DEFAULT_SETTINGS: Settings = {
primaryColor: '#1677ff',
themeMode: 'system',
ktv: false,
lyric2line: true,
progressMemory: true,
showComments: true,
smtc: true,
filenameFormat: 'artist_song',
writeMeta: true,
metaFields: { name: true, artist: true, album: true, cover: true, lyrics: false, date: false },
zipEnabled: false,
batchWriteMeta: true,
batchZip: true,
lyricFormat: 'lrc',
lyricFont: 1,
cookiePrefs: { functional: true, accepted: false },
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
/* 弹窗/抽屉显隐总线:React 状态驱动的 show 类。
* 控制器层一律通过 openModal/closeModal 切换,不再直接 classList.add('show')。 */
import { notify } from '../lib/store'
export type ModalKey =
| 'changelog-modal'
| 'stats-modal'
| 'api-modal'
| 'cookie-modal'
| 'login-modal'
| 'register-modal'
| 'announce-modal'
const _openStack: ModalKey[] = []
let _drawer = false
export function isModalOpen(key: ModalKey): boolean {
return _openStack.indexOf(key) !== -1
}
export function openModal(key: ModalKey) {
if (_openStack.indexOf(key) === -1) _openStack.push(key)
notify()
}
export function closeModal(key: ModalKey) {
const i = _openStack.indexOf(key)
if (i !== -1) _openStack.splice(i, 1)
notify()
}
/** 视觉最上层弹窗(后开在上),无则 null */
export function topmostModal(): ModalKey | null {
return _openStack.length ? _openStack[_openStack.length - 1] : null
}
/** Escape 通用分支:关掉最上层弹窗(含专用清理钩子),返回是否消费了按键 */
export function closeTopmost(): boolean {
const top = topmostModal()
if (!top) return false
if (top === 'stats-modal' || top === 'cookie-modal') {
// 专用关闭(清轮询定时器/复位注册门槛视图),经 __legacyApi 间接调用
import('../controllers/legacy').then((m) => {
const api = (m as any).__legacyApi
if (top === 'stats-modal') api.closeStatsModal()
else api.closeCookieModal()
})
closeModal(top)
return true
}
closeModal(top)
return true
}
export function isDrawerOpen(): boolean {
return _drawer
}
export function openDrawer() {
_drawer = true
notify()
}
export function closeDrawer() {
_drawer = false
notify()
}
export function toggleDrawer() {
_drawer = !_drawer
notify()
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
"allowJs": true
},
"include": ["src"],
"exclude": ["src/controllers/legacy.ts", "src/controllers/auth.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
// Vite 配置:构建产物输出到 frontend/dist,后端 web/routers/index.py 直接服务它。
// 开发模式:API 全在根路径(/song、/search、/auth/...),把非 Vite 内部路径全部
// 反代到本机后端(uvicorn 0.0.0.0:5000),浏览器里前后端如同同源。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const BACKEND = process.env.BACKEND_ORIGIN || 'http://127.0.0.1:5000'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: true,
// 固定产物文件名(不带内容 hash):index.html 字节级稳定,
// tests/test_golden.py 的黄金快照契约才能跨构建成立
rollupOptions: {
output: {
entryFileNames: 'assets/app.js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/app.[ext]',
},
},
},
server: {
port: 5173,
proxy: {
// Vite 自身路径放行,其余(API 与页面)全部反代后端
'/@vite': { target: BACKEND, bypass: (req) => req.url || undefined },
'/@react-refresh': { target: BACKEND, bypass: (req) => req.url || undefined },
'/src': { target: BACKEND, bypass: (req) => req.url || undefined },
'/node_modules': { target: BACKEND, bypass: (req) => req.url || undefined },
'/': { target: BACKEND, changeOrigin: true },
},
},
})