From f43cee1ba0c0cb44bef3d8384157f060531bbdf1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 15 May 2026 16:07:04 +0800 Subject: [PATCH] feat(frontend): implement P0 optimization baseline --- apps/frontend/src/utils/admin-auth.ts | 27 +- .../webhook-events/AdminWebhookEventsView.vue | 15 +- apps/frontend/vite.config.ts | 17 +- docs/frontend-optimization-plan.md | 524 ++++++++++++++++++ 4 files changed, 574 insertions(+), 9 deletions(-) create mode 100644 docs/frontend-optimization-plan.md diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts index 78098e74..cbb374e6 100644 --- a/apps/frontend/src/utils/admin-auth.ts +++ b/apps/frontend/src/utils/admin-auth.ts @@ -33,11 +33,11 @@ export function getAdminRole() { return role } - return 'operator' + return 'support' } export function hasAdminRole(role: 'admin' | 'operator' | 'support') { - if (!getAdminToken()) { + if (!hasAdminSession()) { return false } @@ -74,5 +74,26 @@ export function clearAdminSession() { } export function hasAdminSession() { - return Boolean(getAdminToken()) + const token = getAdminToken() + const expiresAt = getAdminTokenExpiresAt() + + if (!token) { + return false + } + + if (isExpired(expiresAt)) { + clearAdminSession() + return false + } + + return true +} + +function isExpired(expiresAt: string) { + if (!expiresAt) { + return false + } + + const expiresAtMs = Date.parse(expiresAt) + return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now() } diff --git a/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue b/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue index 9ddd1961..4ccf3b9e 100644 --- a/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue +++ b/apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue @@ -30,6 +30,7 @@ const pagination = ref({ }) const canReplay = hasAdminRole('admin') +let latestRequestId = 0 const summary = computed(() => ({ processedCount: items.value.filter((item) => item.processed).length, @@ -38,6 +39,7 @@ const summary = computed(() => ({ })) async function loadEvents(page = pagination.value.page) { + const requestId = ++latestRequestId loading.value = true errorMessage.value = '' @@ -54,12 +56,23 @@ async function loadEvents(page = pagination.value.page) { dateFrom: dateFrom.value.trim(), dateTo: dateTo.value.trim(), }) + + if (requestId !== latestRequestId) { + return + } + items.value = response.data.items pagination.value = response.data.pagination } catch (error) { + if (requestId !== latestRequestId) { + return + } + errorMessage.value = error instanceof Error ? error.message : '读取 webhook 列表失败' } finally { - loading.value = false + if (requestId === latestRequestId) { + loading.value = false + } } } diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 0a7d0139..0f825b24 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -8,6 +8,7 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const allowedHosts = parseAllowedHosts(env.VITE_ALLOWED_HOSTS) return { plugins: [ @@ -28,11 +29,8 @@ export default defineConfig(({ mode }) => { }, }, server: { - // 👇 加入这行,允许通过 IP 和网络访问 - host: true, - // 👇 加入这行,允许所有外部域名(包括 Cloudflare 分配的域名)访问 - allowedHosts: true, - + host: env.VITE_DEV_HOST === 'true', + allowedHosts: env.VITE_ALLOW_ALL_HOSTS === 'true' ? true : allowedHosts, proxy: { '/api': { target: env.VITE_API_TARGET || 'http://localhost:3000', @@ -42,3 +40,12 @@ export default defineConfig(({ mode }) => { }, } }) + +function parseAllowedHosts(value?: string) { + const hosts = value + ?.split(',') + .map((host) => host.trim()) + .filter(Boolean) + + return hosts && hosts.length > 0 ? hosts : ['localhost', '127.0.0.1'] +} diff --git a/docs/frontend-optimization-plan.md b/docs/frontend-optimization-plan.md new file mode 100644 index 00000000..d9ff7a8c --- /dev/null +++ b/docs/frontend-optimization-plan.md @@ -0,0 +1,524 @@ +# apps/frontend 前端优化计划 + +## 背景 + +本计划基于当前 `apps/frontend` 代码现状整理。当前前端使用 Vue 3、Vite、TypeScript、Vue Router、Element Plus,已开启 TypeScript strict 相关检查,路由页面基本采用动态 import,`npm run build` 当前可通过。 + +当前主要问题不是功能不可用,而是后台管理页面增多后,列表页重复逻辑、请求稳定性、登录态安全、构建产物治理和工程规范需要系统化优化。 + +## 优化目标 + +- 提升后台页面的可维护性和复用性。 +- 减少列表页请求竞态和状态错乱风险。 +- 强化管理端登录态和开发服务器安全边界。 +- 规范构建产物和依赖拆包,提升缓存效果。 +- 补齐 lint / format / CI 质量门禁。 +- 为后续后台功能扩展降低开发成本。 + +## 当前观察 + +### 已确认现状 + +- `apps/frontend/package.json` 当前脚本包含 `dev`、`build`、`preview`、`typecheck`。 +- `apps/frontend/tsconfig.app.json` 已开启 `strict`、`noUnusedLocals`、`noUnusedParameters` 等检查。 +- `apps/frontend/src/router/index.ts` 已使用动态 import 拆分页面。 +- `apps/frontend/src/lib/http.ts` 统一封装 axios 请求和后台 401 处理。 +- 后台列表页普遍存在相同的 loading、error、pagination、load、reset 逻辑。 +- `apps/frontend/vite.config.ts` 当前 `server.host` 与 `server.allowedHosts` 配置偏开放。 +- 管理端 token 当前保存在 `localStorage`,`hasAdminSession()` 主要检查 token 是否存在。 + +### 构建验证 + +已执行: + +```bash +cd apps/frontend +npm run build +``` + +结果:构建成功。 + +## 优先级 P0:安全与稳定性 + +### 1. 收紧 Vite dev server 访问控制 + +涉及文件: + +- `apps/frontend/vite.config.ts` + +当前配置: + +```ts +server: { + host: true, + allowedHosts: true, +} +``` + +风险: + +- `allowedHosts: true` 会允许任意 Host 访问开发服务。 +- 如果配合公网隧道或局域网暴露,开发环境边界过宽。 + +建议方案: + +- 默认只允许本地访问。 +- 需要公网调试时通过环境变量显式开启。 +- 支持 Host 白名单,例如 `VITE_ALLOWED_HOSTS`。 + +建议配置方向: + +```ts +const allowedHosts = env.VITE_ALLOWED_HOSTS + ? env.VITE_ALLOWED_HOSTS.split(',').map((host) => host.trim()).filter(Boolean) + : ['localhost', '127.0.0.1'] + +server: { + host: env.VITE_DEV_HOST === 'true', + allowedHosts: env.VITE_ALLOW_ALL_HOSTS === 'true' ? true : allowedHosts, +} +``` + +验收标准: + +- 本地 `npm run dev` 正常。 +- 未设置环境变量时不默认允许任意 Host。 +- 需要公网调试时可以通过环境变量开启。 + +### 2. 完善管理端登录态过期判断 + +涉及文件: + +- `apps/frontend/src/utils/admin-auth.ts` +- `apps/frontend/src/router/index.ts` +- `apps/frontend/src/lib/http.ts` + +当前问题: + +- `hasAdminSession()` 只检查 token 是否存在。 +- `expiresAt` 已存储但前端路由守卫未主动判断是否过期。 +- `getAdminRole()` 在本地 role 缺失时默认返回 `operator`,权限默认值偏宽。 + +建议方案: + +- `hasAdminSession()` 校验 token 和 expiresAt。 +- token 过期时主动 `clearAdminSession()`。 +- `getAdminRole()` 默认值调整为最低权限,或返回空角色并让调用方处理。 +- 保持后端接口鉴权作为最终安全边界,前端仅用于体验优化。 + +验收标准: + +- 过期 token 刷新页面会跳转登录页。 +- role 缺失时不会默认获得 operator 权限。 +- 401 且错误码为认证失效时仍会清理会话。 + +### 3. 列表请求增加竞态保护 + +涉及页面: + +- `apps/frontend/src/views/admin/webhook-events/AdminWebhookEventsView.vue` +- `apps/frontend/src/views/admin/orders/AdminOrdersView.vue` +- `apps/frontend/src/views/admin/tasks/AdminTasksView.vue` +- `apps/frontend/src/views/admin/message-deliveries/AdminMessageDeliveriesView.vue` +- `apps/frontend/src/views/admin/users/AdminUsersView.vue` +- `apps/frontend/src/views/admin/audit-logs/AdminAuditLogsView.vue` +- `apps/frontend/src/views/admin/inventory/AdminInventoryView.vue` + +当前风险: + +- 用户连续查询、翻页或回车搜索时,旧请求可能晚于新请求返回并覆盖新结果。 + +建议方案: + +- 短期使用 `requestId` 忽略过期响应。 +- 中期在 HTTP 层或列表 composable 中支持 `AbortController`。 + +示例方向: + +```ts +let latestRequestId = 0 + +async function loadPage(page = pagination.value.page) { + const requestId = ++latestRequestId + loading.value = true + errorMessage.value = '' + + try { + const response = await fetchList(page) + if (requestId !== latestRequestId) return + + items.value = response.data.items + pagination.value = response.data.pagination + } catch (error) { + if (requestId !== latestRequestId) return + errorMessage.value = error instanceof Error ? error.message : '读取列表失败' + } finally { + if (requestId === latestRequestId) { + loading.value = false + } + } +} +``` + +验收标准: + +- 快速连续点击查询或分页时,页面最终展示最后一次请求结果。 +- loading 状态不会被旧请求提前关闭。 + +## 优先级 P1:后台列表页复用与体验 + +### 4. 抽取通用 `useAdminListPage` + +建议新增: + +- `apps/frontend/src/composables/useAdminListPage.ts` + +目标统一处理: + +- `loading` +- `errorMessage` +- `items` +- `pagination` +- `loadPage` +- `resetPageAndLoad` +- 请求竞态保护 +- 默认错误提示 +- 默认 `pageSize` + +适合改造页面: + +- Webhook 日志 +- 订单列表 +- 任务列表 +- 消息发送记录 +- 审计日志 +- 用户列表 +- 库存列表 + +预期收益: + +- 列表页脚本代码减少约 30% 到 50%。 +- 分页、查询、错误处理行为统一。 +- 后续增加 URL query 同步或请求取消时只需改一处。 + +验收标准: + +- 至少完成 2 个代表页面改造,例如 Webhook 日志和订单列表。 +- 改造后 `npm run typecheck` 与 `npm run build` 通过。 + +### 5. 筛选条件同步到 URL Query + +适合页面: + +- `AdminWebhookEventsView.vue` +- `AdminOrdersView.vue` +- `AdminTasksView.vue` +- `AdminMessageDeliveriesView.vue` +- `AdminAuditLogsView.vue` + +当前问题: + +- 刷新页面后筛选条件丢失。 +- 无法复制当前筛选链接给其他管理员。 + +建议方案: + +- 页面初始化时从 `route.query` 读取筛选条件和页码。 +- 查询、重置、分页时使用 `router.replace()` 更新 query。 +- 空筛选项不写入 URL。 + +验收标准: + +- 刷新页面保留筛选条件。 +- 分享 URL 后能恢复相同列表视图。 +- 重置筛选会清理相关 query。 + +### 6. 统一日期范围组件 + +当前观察: + +- 订单页已经使用 `type="daterange"`。 +- Webhook、审计日志等页面仍使用两个独立日期选择器。 + +建议方案: + +- 日志类、列表类页面统一使用 `el-date-picker type="daterange"`。 +- 内部统一映射为 `dateFrom` 和 `dateTo` API 参数。 + +验收标准: + +- Webhook 日志、审计日志、消息发送记录日期筛选交互一致。 +- API 参数保持兼容。 + +### 7. 统一筛选触发策略 + +当前问题: + +- 输入框通常支持 Enter 查询。 +- select/date 改变后多数页面不自动查询。 +- 不同列表页交互不完全一致。 + +建议二选一: + +- 方案 A:所有筛选项变更后不自动查询,统一点击“查询”。 +- 方案 B:select/date 变更自动查询,文本输入 Enter 或防抖查询。 + +建议优先方案 A,原因是后台列表数据量可能较大,可减少无意请求。 + +验收标准: + +- 后台列表页筛选交互一致。 +- 查询按钮与重置按钮行为一致。 + +## 优先级 P2:工程规范与类型质量 + +### 8. 增加 ESLint / Prettier + +涉及文件: + +- `apps/frontend/package.json` +- `apps/frontend/eslint.config.*` +- `apps/frontend/.prettierrc` 或等价配置 + +当前问题: + +- package scripts 未提供 lint / format。 +- 仅依赖 TypeScript 编译检查,无法覆盖 Vue 模板规范、Promise 处理、风格一致性等问题。 + +建议增加脚本: + +```json +{ + "scripts": { + "lint": "eslint . --ext .ts,.vue", + "format": "prettier --write .", + "format:check": "prettier --check ." + } +} +``` + +建议依赖: + +- `eslint` +- `@eslint/js` +- `typescript-eslint` +- `eslint-plugin-vue` +- `prettier` +- `eslint-config-prettier` + +验收标准: + +- `npm run lint` 可执行。 +- `npm run format:check` 可执行。 +- CI 至少执行 `typecheck`、`lint`、`build`。 + +### 9. 优化 HTTP 封装类型 + +涉及文件: + +- `apps/frontend/src/lib/http.ts` +- `apps/frontend/src/services/**/*` + +当前问题: + +```ts +return http.get>(url, { params }) as unknown as Promise> +``` + +由于 axios interceptor 返回 `response.data`,代码使用 `as unknown as` 消除类型差异。 + +建议方案: + +- 封装明确的 `request()`。 +- 统一返回 `Promise>`。 +- blob 请求单独封装。 + +目标: + +- 减少双重类型断言。 +- 服务层返回值更可信。 +- 错误类型可以逐步标准化。 + +验收标准: + +- `apiGet`、`apiPost`、`apiDelete` 不再依赖 `as unknown as`。 +- 现有 services 类型不退化。 +- `npm run typecheck` 通过。 + +## 优先级 P3:构建产物与性能 + +### 10. 分析并治理 bundle + +当前构建观察: + +- 构建成功。 +- 输出中出现较多 `css-*.js` 小 chunk。 +- Element Plus 相关依赖和样式可能存在进一步优化空间。 + +建议步骤: + +1. 引入 bundle 分析工具。 +2. 查看 Vue、Element Plus、lodash、qrcode、dayjs 等依赖占比。 +3. 评估是否需要配置 `manualChunks`。 +4. 检查 Element Plus 样式自动导入是否导致过多碎片 chunk。 + +可选工具: + +- `rollup-plugin-visualizer` +- Vite 官方构建输出分析 + +验收标准: + +- 输出一份主要 chunk 占比结论。 +- 明确是否需要手动拆分 vendor。 +- 优化后首屏关键 chunk 不变大。 + +### 11. 稳定 vendor chunk 拆分 + +建议方向: + +```ts +build: { + rollupOptions: { + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router'], + 'element-plus': ['element-plus'], + }, + }, + }, +} +``` + +注意事项: + +- 不建议盲目拆太细。 +- 需要结合 bundle 分析结果决定。 +- 后台页面较多时,可以考虑 admin 公共模块分组。 + +验收标准: + +- 构建产物命名更稳定。 +- 主要 vendor 包可长期缓存。 +- 页面懒加载不被破坏。 + +## 优先级 P4:大型页面拆分 + +### 12. 拆分超大 Vue 文件 + +当前行数较大的文件包括: + +- `apps/frontend/src/views/admin/fulfillment/AdminKuaishouCloudFulfillmentView.vue` +- `apps/frontend/src/views/admin/tasks/AdminTaskDetailView.vue` +- `apps/frontend/src/views/admin/fulfillment/AdminFulfillmentBindingsView.vue` +- `apps/frontend/src/views/admin/inventory/AdminInventoryView.vue` +- `apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue` + +建议拆分维度: + +- 筛选区组件 +- 表格区组件 +- 表单区组件 +- 弹窗组件 +- 业务 composable +- 数据转换函数 + +验收标准: + +- 单个 Vue 文件尽量控制在 300 到 500 行以内。 +- 业务状态集中在 composable 或页面容器中。 +- 展示组件保持 props / emits 清晰。 + +## 分阶段执行计划 + +### 阶段一:安全与稳定性基线 + +范围: + +- 收紧 `vite.config.ts` dev server 配置。 +- `hasAdminSession()` 增加过期判断。 +- 给 Webhook 列表增加请求竞态保护。 + +验证: + +```bash +cd apps/frontend +npm run typecheck +npm run build +``` + +### 阶段二:列表页抽象 + +范围: + +- 新增 `useAdminListPage`。 +- 先改造 Webhook 日志和订单列表。 +- 确认抽象合理后推广到任务、消息、用户、审计日志。 + +验证: + +- 手动检查查询、重置、分页、错误提示。 +- `npm run typecheck` +- `npm run build` + +### 阶段三:筛选体验统一 + +范围: + +- 日期范围统一为 `daterange`。 +- 列表页 URL query 同步。 +- 统一查询触发策略。 + +验证: + +- 刷新页面保留筛选。 +- 分享链接可恢复筛选。 +- 重置筛选清理 URL。 + +### 阶段四:工程质量门禁 + +范围: + +- 增加 ESLint / Prettier。 +- 增加 lint / format:check 脚本。 +- CI 或本地验证流程加入 typecheck、lint、build。 + +验证: + +```bash +cd apps/frontend +npm run typecheck +npm run lint +npm run build +``` + +### 阶段五:构建与大型页面治理 + +范围: + +- Bundle 分析。 +- 评估并配置 manualChunks。 +- 拆分 900 行以上的大型 Vue 页面。 + +验证: + +- 构建成功。 +- 产物 chunk 数量和体积有明确对比。 +- 页面功能无回归。 + +## 风险与注意事项 + +- 列表页抽象不要一次性改造所有页面,建议先选 2 个页面试点。 +- URL query 同步要避免 watcher 循环触发请求。 +- token 存储机制如果改为 Cookie,需要后端配合,不建议前端单独推进。 +- manualChunks 需要基于分析结果调整,避免拆包过度导致请求数过多。 +- ESLint 首次接入可能暴露大量历史问题,建议先设置合理规则,再逐步收紧。 + +## 推荐近期落地清单 + +1. 修改 `vite.config.ts`,收紧 `allowedHosts`。 +2. 修改 `admin-auth.ts`,增加 token 过期判断。 +3. 在 `AdminWebhookEventsView.vue` 试点请求竞态保护和日期范围统一。 +4. 抽取 `useAdminListPage`,先改造 Webhook 和订单列表。 +5. 增加 ESLint / Prettier 基础配置。 +6. 引入 bundle 分析,确认 Element Plus 和 CSS chunk 优化方向。