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