Archived
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:
1 parent
0dbbb3ed63
commit
3a089caff2
15 files changed
+11089
No files matched your search
@@ -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>
|
||||
Generated
+1630
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/* 旧脚本通过 window.* 全局钩子双向协作(与旧版一致,暂保留该机制)。
|
||||
* 这里放宽 window 索引类型,避免逐个声明钩子。 */
|
||||
interface Window {
|
||||
[key: string]: any
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user