refactor(web): migrate frontend to React 19 + Refine v5 + shadcn/ui stack

技术栈切换(按 AGENTS.md 批准栈):react-admin/MUI → Refine v5 + shadcn/ui
(Tailwind CSS v4),Vite 8 + React 19 + react-router v7 hash 路由。

- 全部 9 个页面重写:AccountsPage/BrowsersPage(BrowserList/CreatePage/Detail)/
  BrowserImagesPage/GatewaysPage/NetworkExitsPage/TasksPage/TracePages/
  DraftPage/AuditPage;自建 lib/ui.jsx 组件库与 Layout(侧导航 + Outlet)
- 简单创建/编辑改 modal(账号/网关/镜像/出口),环境创建走独立页
  /browsers/new;列表按信息密度选表格或移动卡片双形态
- 图标统一 RemixIcon;领域动作保持显式动词(start/stop/upgrade/recycle
  等,不伪装成 CRUD update)
- LoginPage:Basic 凭证存 localStorage;401 清凭证并跳 #/login;
  AuthGate 改为渲染期读 localStorage(react-router v7 的 pushState 不触发
  hashchange,事件订阅会在导航后拿到过期状态导致登录后卡死)
- dataProvider 适配 Refine v5 契约:所有方法收单个参数对象
  ({resource, pagination, filters, meta, variables}),create/update/
  delete/deleteOne 语义对齐
- Modal 关闭时焦点还给触发元素;Select 保持原生 button/combobox 语义
- e2e 补登录前置;账号创建流程同步 modal 交互

验证:npm test 65/65 通过;覆盖率 71.5% stmts / 76.3% lines(门槛 65%);
vite build 成功;playwright e2e 9/9 通过。
This commit is contained in:
2026-09-02 13:40:34 +08:00
parent 5394b19916
commit 7ecad93821
44 changed files with 5516 additions and 3240 deletions
+61 -34
View File
@@ -1,10 +1,24 @@
import { HttpError } from 'ra-core'
// 401 = 未认证:抹去凭证并引导到登录页。开发代理下 401 由后端直接返回。
const unauthorized = () => {
localStorage.removeItem('creatorhub.auth')
if (!location.hash.startsWith('#/login')) location.hash = '#/login'
return Object.assign(new Error('认证已过期,请重新登录'), { status: 401 })
}
async function request(path = '', options) {
const response = await fetch(`/api${path}`, options)
const auth = localStorage.getItem('creatorhub.auth')
const headers = { ...options?.headers }
if (auth) headers.Authorization = `Basic ${btoa(auth)}`
let response
try {
response = await fetch(`/api${path}`, { ...options, headers })
} catch (networkError) {
throw Object.assign(new Error(`网络请求失败:${networkError.message}`), { status: 0 })
}
if (response.status === 401) throw unauthorized()
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new HttpError(body.error || `请求失败 (${response.status})`, response.status, body)
throw Object.assign(new Error(body.error || `请求失败 (${response.status})`), { status: response.status, body })
}
return response.status === 204 ? null : response.json()
}
@@ -30,48 +44,54 @@ const resourcePaths = {
'network-exits': '/network-exits',
}
const withID = (record, fallback) => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name ?? fallback })
export const dataProvider = {
async getList(resource, params = {}) {
// Refine v5 契约:所有方法收单个参数对象(getList 收 {resource, pagination, filters, meta})。
async getList({ resource, pagination = {}, filters = [], meta = {} }) {
const path = resourcePaths[resource]
if (!path) return unsupported(resource, 'getList')
const filterKeys = {
drafts: ['account_id'], confirmations: ['draft_id'], tasks: ['account_id', 'draft_id', 'state'],
audit: ['account_id', 'task_id', 'attempt_id', 'browser_env_alias', 'network_exit_id', 'event_type', 'from', 'to'],
}[resource] || []
const query = new URLSearchParams(filterKeys.flatMap(key => params.filter?.[key] ? [[key, params.filter[key]]] : []))
// filters 为 [{field, value, operator}];meta 是 Refine 透传的查询上下文。
const valueOf = field => {
const direct = meta?.[field]
if (direct !== undefined) return direct
const entry = filters?.find(item => item.field === field)
return entry?.value !== undefined && entry.value !== null && entry.value !== '' ? entry.value : undefined
}
const query = new URLSearchParams(filterKeys(resource).flatMap(field => valueOf(field) !== undefined ? [[field, valueOf(field)]] : []))
if (resource === 'audit') {
query.set('page', params.pagination?.page || 1)
query.set('page_size', params.pagination?.perPage || 25)
query.set('page', pagination.currentPage || pagination.page || 1)
query.set('page_size', pagination.pageSize || pagination.perPage || 25)
}
const records = await request(`${path}${query.size ? `?${query}` : ''}`)
const data = Array.isArray(records) ? records : records.data
return { data: data.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.total ?? data.length }
return { data: data.map((record, index) => withID(record, index)), total: records.total ?? data.length }
},
async create(resource, { data }) {
const path = resourcePaths[resource]
if (!path) return unsupported(resource, 'create')
const created = await request(path, jsonOptions('POST', data))
return { data: { ...data, ...created, id: created?.id ?? created?.alias ?? created?.version ?? created?.name ?? data.id ?? data.alias } }
},
async update(resource, { id, data }) {
const path = resourcePaths[resource]
if (!path || resource === 'browsers') return unsupported(resource, 'update')
// browser-images 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
const { version: _ignored, ...rest } = data ?? {}
await request(`${path}/${encodeURIComponent(id)}`, jsonOptions('PUT', rest))
return { data: { ...rest, id } }
},
async delete(resource, { id }) {
const path = resourcePaths[resource]
if (!path) return unsupported(resource, 'delete')
await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { data: { id } }
},
async getOne(resource, { id }) {
async getOne({ resource, id }) {
const path = resourcePaths[resource]
if (!path || !['accounts', 'network-exits', 'browsers', 'drafts', 'confirmations', 'tasks', 'attempts'].includes(resource)) return unsupported(resource, 'getOne')
const record = await request(`${path}/${encodeURIComponent(id)}`)
return { data: { ...record, id: record.id ?? id } }
return { data: withID(record, id) }
},
async create({ resource, variables }) {
const path = resourcePaths[resource]
if (!path) return unsupported(resource, 'create')
const created = await request(path, jsonOptions('POST', variables))
return { data: withID({ ...variables, ...created }, variables.id ?? variables.alias) }
},
async update({ resource, id, variables }) {
const path = resourcePaths[resource]
if (!path || resource === 'browsers') return unsupported(resource, 'update')
// browser-images 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
const { version: _ignored, ...rest } = variables ?? {}
await request(`${path}/${encodeURIComponent(id)}`, jsonOptions('PUT', rest))
return { data: { ...rest, id } }
},
async deleteOne({ resource, id }) {
const path = resourcePaths[resource]
if (!path || resource !== 'browser-images') return unsupported(resource, 'deleteOne')
await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { data: { id } }
},
getMany: resource => unsupported(resource, 'getMany'),
getManyReference: resource => unsupported(resource, 'getManyReference'),
@@ -119,3 +139,10 @@ export const dataProvider = {
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
},
}
function filterKeys(resource) {
return {
drafts: ['account_id'], confirmations: ['draft_id'], tasks: ['account_id', 'draft_id', 'state'],
audit: ['account_id', 'task_id', 'attempt_id', 'browser_env_alias', 'network_exit_id', 'event_type', 'from', 'to'],
}[resource] || []
}