diff --git a/.gstack/qa-reports/qa-report-localhost-5604-2026-08-25.md b/.gstack/qa-reports/qa-report-localhost-5604-2026-08-25.md new file mode 100644 index 0000000..70c5312 --- /dev/null +++ b/.gstack/qa-reports/qa-report-localhost-5604-2026-08-25.md @@ -0,0 +1,54 @@ +# WonderQ 三端人工验收报告 + +- 验收时间:2026-08-25 +- 验收范围:Vue 管理端 `http://localhost:5604`、React 过渡端 `http://localhost:5602`、MiniAPP H5 `http://localhost:5173` +- 首轮结论:未通过;本轮修复后阻塞项和控制台告警均已回归通过,但整体业务验收仍未完成。 + +## 已通过 + +- Vue 管理端登录态下可进入仪表盘、首页配置、玩法与详情、管家管理、需求线索及 RBAC 菜单。 +- 需求线索列表可加载,状态字段可见;本次未修改业务数据。 +- Vue 路由菜单点击后 URL 和菜单高亮基本同步。 +- React 过渡端可正常打开登录页,浏览器控制台无 error/warning。 +- MiniAPP H5 首页可正常打开,公共 API 内容可见,浏览器控制台无 error/warning。 +- Vue 管理端本次浏览未发现运行时 error。 +- 修复后连续切换用户、角色、菜单、部门页面,URL、标题、菜单高亮和记录均对应一致。 +- 修复后新会话浏览控制台无 error/warning。 +- 2026-08-26 增量复验:首页配置已接入全部 7 类站点模块的新增、编辑、删除和可用模块排序入口;空态、编辑器字段、单例限制、必填标题和 HTTP(S) 图片地址校验可用,未修改开发库业务数据。 +- 2026-08-26 增量复验:玩法分类、路线和路线详情已接入新增、编辑、删除、排序、详情字段、管家关联及详情图片上传入口;其余四类站点配置已接入统一编辑器,未执行真实写入。 +- 2026-08-26 增量复验:管家顾问页已接入顾问 CRUD、启用状态、排序、删除确认、头像/二维码上传和服务详情编辑;未执行真实写入。 +- 2026-08-26 增量复验:重新确认有效 Vue 会话后,线索列表、类型筛选、状态筛选和详情展示均通过;补充修复 `LeadsPage` 缺少 `onMounted(load)` 导致页面永久 loading 的问题,未执行真实状态写入。 +- 2026-08-26 增量复验:媒体页已读取现有素材并接入分组批量上传、图片类型/5MB 校验;发布与重置页已接入权限控制、进行中禁用和二次确认,未触发发布或重置。 + +## 阻塞问题 + +### ISSUE-001:RBAC 系统资源页面切换后内容不刷新 + +- 严重级别:High +- 影响:从“用户管理”点击“角色管理”“菜单管理”或“部门管理”时,URL 已切换,但页面标题和记录仍停留在用户管理;人工无法可靠验收角色、菜单、部门页面。 +- 复现步骤: + 1. 打开 Vue 管理端并进入“用户管理”。 + 2. 点击“角色管理”。 + 3. 观察地址为 `/admin/system/roles`,页面标题仍为“用户管理”,记录仍为用户 JSON。 +- 实际结果:页面显示 `用户管理`,而地址为 `http://localhost:5604/admin/system/roles`;当前菜单高亮为“角色管理”。 +- 证据:`screenshots/issue-001-system-resource-stale.png` +- 修复结果:已通过动态路由去重、参数页面重新加载和页面组件 key 处理;回归证据:`screenshots/issue-001-after-fix.png`。 + +## 非阻塞问题与覆盖缺口 + +### ISSUE-002:Vue Router 启动告警(已修复) + +- 严重级别:Medium +- 控制台出现:路由名 `admin` 包含无名称的空路径子路由。当前页面仍可使用,但会增加路由解析回归风险。 +- 修复结果:空路径子路由已命名为 `admin-index`;新会话回归未再出现该告警。 + +### COVERAGE-001:业务写入能力尚未完成迁移 + +- 首页配置的顶部轮播、万趣用车、用车服务已完成 Vue 写入代码迁移;本次仅进行了非破坏性表单和接口契约验证,真实数据写入仍待人工确认。 +- 线索列表、类型/状态筛选和详情展示已在有效后台会话下复验;本次未触发真实状态写入。 +- 媒体上传、发布和重置只完成入口与确认闸门验收,真实动作仍待人工确认。 +- 系统用户、角色、菜单、部门页面尚未形成完整可编辑验收闭环;ISSUE-001 已解除。 + +## 验收判定 + +当前判定为“阻塞项已解除,业务覆盖验收进行中”。首页、玩法/详情、站点配置、管家、线索读取/筛选/详情、媒体和发布/重置入口代码已接入;线索状态写入、媒体真实上传、发布和重置仍需人工确认,核心 RBAC 流程完成后才能进入旧 React 删除前的最终人工确认。 diff --git a/.gstack/qa-reports/screenshots/issue-001-after-fix.png b/.gstack/qa-reports/screenshots/issue-001-after-fix.png new file mode 100644 index 0000000..812dbe3 Binary files /dev/null and b/.gstack/qa-reports/screenshots/issue-001-after-fix.png differ diff --git a/.gstack/qa-reports/screenshots/issue-001-system-resource-stale.png b/.gstack/qa-reports/screenshots/issue-001-system-resource-stale.png new file mode 100644 index 0000000..f0df655 Binary files /dev/null and b/.gstack/qa-reports/screenshots/issue-001-system-resource-stale.png differ diff --git a/.superpowers/brainstorm/2026-08-25-admin-refactor/content/migration-options.html b/.superpowers/brainstorm/2026-08-25-admin-refactor/content/migration-options.html new file mode 100644 index 0000000..53a47ad --- /dev/null +++ b/.superpowers/brainstorm/2026-08-25-admin-refactor/content/migration-options.html @@ -0,0 +1,63 @@ +

WonderQ 三端重构:迁移路径

+

先选择整体切换方式。三种方案都保留现有 MiniAPP 业务;差别在 Admin UI 与权限内核如何落地。

+ +
+
+
目标形态(共同部分)
+
+
+ Vue3 + Element Plus
Admin UI / RBAC +
+
+ Python FastAPI
JWT · Redis · PostgreSQL · Docker +
+
+ Vue3 uni-app
H5 / 微信小程序 +
+
+
+
+ +
+
+
+
+
React UI
旧入口
+
Vue3 UI
新入口
+
+
+
+

A · 并行迁移(推荐)

+

新建 Vue3 管理端,按权限、首页、玩法、线索等垂直切片逐步替换;旧 React 端在过渡期继续可用。

+

优点

  • 风险可控
  • 可逐模块验收

代价

  • 短期维护两套 UI
+
+
+ +
+
+
同目录原地替换
React → Vue3 一次切换
+
+
+

B · 原地重写

+

直接把现有 `WonderQ-Admin-UI` 替换为 Vue3 + Element Plus,复用后端契约并一次性切换入口。

+

优点

  • 最终目录干净
  • 没有双 UI

代价

  • 回滚成本高
  • 业务功能容易遗漏
+
+
+ +
+
+
+
RBAC + JWT/Redis + 审计
+
Vue3 Admin UI 逐模块接入
+
MiniAPP 保持现有 Public API
+
+
+
+

C · 权限内核优先

+

先稳定后台用户、角色、菜单、按钮权限和 Redis 会话/黑名单,再以垂直业务切片重构 Vue3 UI。

+

优点

  • 权限基础先固定
  • 业务迁移边界清晰

代价

  • 前期后端工作较多
  • 需先确定权限模型
+
+
+
+ +

请点击一个你倾向的方案;如果想组合方案,也可以在终端说明。

diff --git a/.superpowers/brainstorm/2026-08-25-admin-refactor/content/waiting-1.html b/.superpowers/brainstorm/2026-08-25-admin-refactor/content/waiting-1.html new file mode 100644 index 0000000..633d684 --- /dev/null +++ b/.superpowers/brainstorm/2026-08-25-admin-refactor/content/waiting-1.html @@ -0,0 +1,3 @@ +
+

已记录迁移方案选择,权限功能边界继续在终端确认。

+
diff --git a/.superpowers/brainstorm/2026-08-25-admin-refactor/state/server-stopped b/.superpowers/brainstorm/2026-08-25-admin-refactor/state/server-stopped new file mode 100644 index 0000000..fa96980 --- /dev/null +++ b/.superpowers/brainstorm/2026-08-25-admin-refactor/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1787667962684} diff --git a/.superpowers/brainstorm/2026-08-25-admin-refactor/state/server.pid b/.superpowers/brainstorm/2026-08-25-admin-refactor/state/server.pid new file mode 100644 index 0000000..5ee544d --- /dev/null +++ b/.superpowers/brainstorm/2026-08-25-admin-refactor/state/server.pid @@ -0,0 +1 @@ +21024 \ No newline at end of file diff --git a/WonderQ-Admin-UI-Vue/src/api/client-operations.test.ts b/WonderQ-Admin-UI-Vue/src/api/client-operations.test.ts new file mode 100644 index 0000000..9250e65 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/api/client-operations.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createConciergeAdvisor, + deleteConciergeAdvisor, + getConcierge, + getLeads, + getMediaAssets, + publishSite, + resetGuizhouContent, + reorderConciergeAdvisors, + updateConciergeAdvisor, + updateLeadStatus, +} from "@/api/client"; + +describe("operations client", () => { + afterEach(() => vi.restoreAllMocks()); + + it("keeps concierge CRUD and reorder endpoints stable", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(200, { advisors: [] })) + .mockResolvedValueOnce(jsonResponse(201, { id: "advisor-1", name: "顾问" })) + .mockResolvedValueOnce(jsonResponse(200, { id: "advisor-1", name: "新顾问" })) + .mockResolvedValueOnce(jsonResponse(200, { items: [{ id: "advisor-1" }] })) + .mockResolvedValueOnce(jsonResponse(200, { id: "advisor-1" })); + vi.stubGlobal("fetch", fetchMock); + + await getConcierge(); + await createConciergeAdvisor({ avatar: "https://example.com/a.jpg", name: "顾问", role: "Advisor", details: [], qrImage: "https://example.com/q.png" }); + await updateConciergeAdvisor("advisor-1", { name: "新顾问" }); + await reorderConciergeAdvisors(["advisor-1"]); + await deleteConciergeAdvisor("advisor-1"); + + expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + ["/api/admin/concierge/advisors", undefined], + ["/api/admin/concierge/advisors", "POST"], + ["/api/admin/concierge/advisors/advisor-1", "PATCH"], + ["/api/admin/concierge/advisors/reorder", "PATCH"], + ["/api/admin/concierge/advisors/advisor-1", "DELETE"], + ]); + }); + + it("supports filtered leads, media listing, publish and reset contracts", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(200, { items: [] })) + .mockResolvedValueOnce(jsonResponse(200, { id: "lead-1", status: "contacted" })) + .mockResolvedValueOnce(jsonResponse(200, { items: [] })) + .mockResolvedValueOnce(jsonResponse(201, { id: "version-1", title: "manual", publishedAt: "2026-08-26T00:00:00Z" })) + .mockResolvedValueOnce(jsonResponse(200, { siteVersionId: "version-2" })); + vi.stubGlobal("fetch", fetchMock); + + await getLeads({ leadType: "vehicle", status: "new", keyword: "贵州", take: 50 }); + await updateLeadStatus("lead-1", "contacted"); + await getMediaAssets(); + await publishSite(); + await resetGuizhouContent(); + + expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + ["/api/admin/leads?leadType=vehicle&status=new&keyword=%E8%B4%B5%E5%B7%9E&take=50", undefined], + ["/api/admin/leads/lead-1/status", "PATCH"], + ["/api/admin/media-assets", undefined], + ["/api/admin/publish", "POST"], + ["/api/admin/reset-guizhou-content", "POST"], + ]); + }); +}); + +function jsonResponse(code: number, data: unknown) { + return { status: code, json: async () => ({ code, msg: "success", data }) }; +} diff --git a/WonderQ-Admin-UI-Vue/src/api/client-site-config.test.ts b/WonderQ-Admin-UI-Vue/src/api/client-site-config.test.ts new file mode 100644 index 0000000..21a62d5 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/api/client-site-config.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createSiteConfigItem, + deleteSiteConfigItem, + reorderSiteConfigItems, + updateSiteConfigItem, +} from "@/api/client"; + +describe("site config client", () => { + afterEach(() => vi.restoreAllMocks()); + + it("uses the existing site config CRUD and reorder contract", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(201, { id: "hero-1", title: "首页轮播" })) + .mockResolvedValueOnce(jsonResponse(200, { id: "hero-1", title: "更新后的轮播" })) + .mockResolvedValueOnce(jsonResponse(200, { id: "hero-1" })) + .mockResolvedValueOnce(jsonResponse(200, { items: [{ id: "hero-2" }, { id: "hero-1" }] })); + vi.stubGlobal("fetch", fetchMock); + + await createSiteConfigItem("heroSlides", { title: "首页轮播", isActive: true, sortOrder: 0 }); + await updateSiteConfigItem("heroSlides", "hero-1", { title: "更新后的轮播" }); + await deleteSiteConfigItem("heroSlides", "hero-1"); + await reorderSiteConfigItems("heroSlides", ["hero-2", "hero-1"]); + + expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + ["/api/admin/site-config/heroSlides", "POST"], + ["/api/admin/site-config/heroSlides/hero-1", "PATCH"], + ["/api/admin/site-config/heroSlides/hero-1", "DELETE"], + ["/api/admin/site-config/heroSlides/reorder", "PATCH"], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[3][1]?.body))).toEqual({ itemIds: ["hero-2", "hero-1"] }); + }); +}); + +function jsonResponse(code: number, data: unknown) { + return { + status: code, + json: async () => ({ code, msg: "success", data }), + }; +} diff --git a/WonderQ-Admin-UI-Vue/src/api/client-system.test.ts b/WonderQ-Admin-UI-Vue/src/api/client-system.test.ts new file mode 100644 index 0000000..661b55e --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/api/client-system.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createSystemDept, + getSystemDepts, + getSystemMenus, + getSystemRoles, + getSystemUsers, + updateSystemDept, + updateSystemMenu, + updateSystemRole, + updateSystemUser, +} from "@/api/client"; + +describe("system RBAC client", () => { + afterEach(() => vi.restoreAllMocks()); + + it("keeps RBAC list and update endpoints stable", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(200, { items: [] })) + .mockResolvedValueOnce(jsonResponse(200, { items: [] })) + .mockResolvedValueOnce(jsonResponse(200, { items: [] })) + .mockResolvedValueOnce(jsonResponse(200, { items: [] })) + .mockResolvedValueOnce(jsonResponse(200, { id: "user-1" })) + .mockResolvedValueOnce(jsonResponse(200, { id: "role-1" })) + .mockResolvedValueOnce(jsonResponse(200, { id: "menu-1" })) + .mockResolvedValueOnce(jsonResponse(200, { id: "dept-1" })) + .mockResolvedValueOnce(jsonResponse(201, { id: "dept-2" })); + vi.stubGlobal("fetch", fetchMock); + + await getSystemUsers(); + await getSystemRoles(); + await getSystemMenus(); + await getSystemDepts(); + await updateSystemUser("user-1", { name: "新名称" }); + await updateSystemRole("role-1", { description: "验收描述" }); + await updateSystemMenu("menu-1", { name: "验收菜单" }); + await updateSystemDept("dept-1", { name: "验收部门" }); + await createSystemDept({ name: "临时部门", parentId: null, sortOrder: 99, isActive: true }); + + expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + ["/api/admin/system/users", undefined], + ["/api/admin/system/roles", undefined], + ["/api/admin/system/menus", undefined], + ["/api/admin/system/depts", undefined], + ["/api/admin/system/users/user-1", "PATCH"], + ["/api/admin/system/roles/role-1", "PATCH"], + ["/api/admin/system/menus/menu-1", "PATCH"], + ["/api/admin/system/depts/dept-1", "PATCH"], + ["/api/admin/system/depts", "POST"], + ]); + }); +}); + +function jsonResponse(code: number, data: unknown) { + return { status: code, json: async () => ({ code, msg: "success", data }) }; +} diff --git a/WonderQ-Admin-UI-Vue/src/api/client-wanfa.test.ts b/WonderQ-Admin-UI-Vue/src/api/client-wanfa.test.ts new file mode 100644 index 0000000..2cd54b0 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/api/client-wanfa.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createDetail, + createWanfaCategory, + createWanfaRoute, + deleteWanfaRoute, + getDetails, + reorderWanfaRoutes, + uploadMediaAsset, + updateDetail, +} from "@/api/client"; + +describe("wanfa and detail client", () => { + afterEach(() => vi.restoreAllMocks()); + + it("keeps nested route and independent detail endpoints stable", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(201, { id: "category-1", label: "亲子路线", routes: [] })) + .mockResolvedValueOnce(jsonResponse(201, { id: "route-1" })) + .mockResolvedValueOnce(jsonResponse(200, { id: "detail-1", key: "route-1" })) + .mockResolvedValueOnce(jsonResponse(200, { details: [] })) + .mockResolvedValueOnce(jsonResponse(200, { id: "detail-1", key: "route-1" })) + .mockResolvedValueOnce(jsonResponse(200, { items: [{ id: "route-2" }, { id: "route-1" }] })) + .mockResolvedValueOnce(jsonResponse(200, { id: "route-1" })); + vi.stubGlobal("fetch", fetchMock); + + await createWanfaCategory({ label: "亲子路线" }); + await createWanfaRoute("category-1", { title: "路线", subtitle: "副标题", image: "https://example.com/route.jpg", routeCount: 4, demandKeyword: "亲子" }); + await createDetail({ key: "route-1", eyebrow: "玩法", duration: "5天4晚", title: "路线详情", subtitle: "副标题", intro: "介绍", highlights: [], included: [], excluded: [], notes: [], gallery: [], conciergeAdvisorId: null, isActive: true }); + await getDetails(); + await updateDetail("detail-1", { title: "更新详情" }); + await reorderWanfaRoutes("category-1", ["route-2", "route-1"]); + await deleteWanfaRoute("category-1", "route-1"); + + expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + ["/api/admin/wanfa/categories", "POST"], + ["/api/admin/wanfa/categories/category-1/routes", "POST"], + ["/api/admin/details", "POST"], + ["/api/admin/details", undefined], + ["/api/admin/details/detail-1", "PATCH"], + ["/api/admin/wanfa/categories/category-1/routes/reorder", "PATCH"], + ["/api/admin/wanfa/categories/category-1/routes/route-1", "DELETE"], + ]); + }); + + it("uploads detail media through the existing multipart endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(201, { id: "asset-1", url: "https://example.com/detail.jpg" })); + vi.stubGlobal("fetch", fetchMock); + const file = new File(["image"], "detail.jpg", { type: "image/jpeg" }); + + const asset = await uploadMediaAsset(file, "wanfa-detail"); + + expect(asset.url).toBe("https://example.com/detail.jpg"); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/admin/media-assets/upload"); + expect(options?.method).toBe("POST"); + expect(options?.body).toBeInstanceOf(FormData); + expect((options?.body as FormData).get("group")).toBe("wanfa-detail"); + }); +}); + +function jsonResponse(code: number, data: unknown) { + return { status: code, json: async () => ({ code, msg: "success", data }) }; +} diff --git a/WonderQ-Admin-UI-Vue/src/api/client.ts b/WonderQ-Admin-UI-Vue/src/api/client.ts index f95a264..5014887 100644 --- a/WonderQ-Admin-UI-Vue/src/api/client.ts +++ b/WonderQ-Admin-UI-Vue/src/api/client.ts @@ -1,4 +1,45 @@ -import type { ApiErrorResponse, ApiResponse, AuthPayload, AdminProfile, Dashboard, Lead, SiteConfig, WanfaCategory, ConciergeAdvisor } from "@/types"; +import type { + ApiErrorResponse, + ApiResponse, + AuthPayload, + AdminProfile, + ConciergeAdvisorCreate, + ConciergeAdvisorPatch, + Dashboard, + DetailCreate, + DetailPatch, + DetailRecord, + Lead, + LeadStatus, + LeadType, + MediaAsset, + ResetContentResult, + SiteConfig, + SiteConfigItem, + SiteItemPatch, + SiteModule, + SiteVersion, + WanfaCategory, + WanfaCategoryCreate, + WanfaCategoryPatch, + WanfaRoute, + WanfaRouteCreate, + WanfaRoutePatch, + ConciergeAdvisor, + SystemDeptCreate, + SystemDeptItem, + SystemDeptPatch, + SystemMenuCreate, + SystemMenuItem, + SystemMenuPatch, + SystemRoleCreate, + SystemRoleItem, + SystemRolePatch, + SystemUserCreate, + SystemUserItem, + SystemUserPatch, +} from "@/types"; +import { buildLeadQuery } from "@/lib/leads"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; let accessToken: string | null = null; @@ -94,12 +135,97 @@ export const getProfile = () => request("/api/admin/system/profile export const logout = () => request<{ ok: boolean }>("/api/admin/auth/logout", { method: "POST" }, false); export const getDashboard = () => request("/api/admin/dashboard"); export const getSiteConfig = () => request("/api/admin/site-config"); +export type SiteConfigItemResponse = SiteConfigItem | { item: SiteConfigItem }; + +function unwrapSiteConfigItem(response: SiteConfigItemResponse) { + return "item" in response ? response.item : response; +} + +export async function createSiteConfigItem(module: SiteModule, input: SiteItemPatch) { + const response = await request(`/api/admin/site-config/${module}`, { + method: "POST", + body: JSON.stringify(input), + }); + return unwrapSiteConfigItem(response); +} + +export async function updateSiteConfigItem(module: SiteModule, id: string, input: SiteItemPatch) { + const response = await request(`/api/admin/site-config/${module}/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); + return unwrapSiteConfigItem(response); +} + +export const deleteSiteConfigItem = (module: SiteModule, id: string) => + request<{ id: string }>(`/api/admin/site-config/${module}/${id}`, { method: "DELETE" }); + +export const reorderSiteConfigItems = (module: SiteModule, itemIds: string[]) => + request<{ items: SiteConfigItem[] }>(`/api/admin/site-config/${module}/reorder`, { + method: "PATCH", + body: JSON.stringify({ itemIds }), + }); + export const getWanfa = async () => (await request<{ categories: WanfaCategory[] }>("/api/admin/wanfa/categories")).categories; +export const createWanfaCategory = (input: WanfaCategoryCreate) => + request("/api/admin/wanfa/categories", { method: "POST", body: JSON.stringify(input) }); +export const updateWanfaCategory = (categoryId: string, input: WanfaCategoryPatch) => + request(`/api/admin/wanfa/categories/${categoryId}`, { method: "PATCH", body: JSON.stringify(input) }); +export const deleteWanfaCategory = (categoryId: string) => + request<{ id: string }>(`/api/admin/wanfa/categories/${categoryId}`, { method: "DELETE" }); +export const reorderWanfaCategories = (itemIds: string[]) => + request<{ items: WanfaCategory[] }>("/api/admin/wanfa/categories/reorder", { method: "PATCH", body: JSON.stringify({ itemIds }) }); +export const createWanfaRoute = (categoryId: string, input: WanfaRouteCreate) => + request(`/api/admin/wanfa/categories/${categoryId}/routes`, { method: "POST", body: JSON.stringify(input) }); +export const updateWanfaRoute = (categoryId: string, routeId: string, input: WanfaRoutePatch) => + request(`/api/admin/wanfa/categories/${categoryId}/routes/${routeId}`, { method: "PATCH", body: JSON.stringify(input) }); +export const deleteWanfaRoute = (categoryId: string, routeId: string) => + request<{ id: string }>(`/api/admin/wanfa/categories/${categoryId}/routes/${routeId}`, { method: "DELETE" }); +export const reorderWanfaRoutes = (categoryId: string, itemIds: string[]) => + request<{ items: WanfaRoute[] }>(`/api/admin/wanfa/categories/${categoryId}/routes/reorder`, { method: "PATCH", body: JSON.stringify({ itemIds }) }); +export const getDetails = async () => (await request<{ details: DetailRecord[] }>("/api/admin/details")).details; +export const uploadMediaAsset = (file: File, group = "general") => { + const form = new FormData(); + form.set("file", file); + form.set("group", group); + return request("/api/admin/media-assets/upload", { method: "POST", body: form }); +}; +export const createDetail = (input: DetailCreate) => + request("/api/admin/details", { method: "POST", body: JSON.stringify(input) }); +export const updateDetail = (detailId: string, input: DetailPatch) => + request(`/api/admin/details/${detailId}`, { method: "PATCH", body: JSON.stringify(input) }); +export const deleteDetail = (detailId: string) => request<{ id: string }>(`/api/admin/details/${detailId}`, { method: "DELETE" }); +export const reorderDetails = (itemIds: string[]) => + request<{ items: DetailRecord[] }>("/api/admin/details/reorder", { method: "PATCH", body: JSON.stringify({ itemIds }) }); export const getConcierge = async () => (await request<{ advisors: ConciergeAdvisor[] }>("/api/admin/concierge/advisors")).advisors; -export const getLeads = () => request<{ items: Lead[] }>("/api/admin/leads?take=50"); -export const updateLeadStatus = (leadId: string, status: string) => +export const createConciergeAdvisor = (input: ConciergeAdvisorCreate) => + request("/api/admin/concierge/advisors", { method: "POST", body: JSON.stringify(input) }); +export const updateConciergeAdvisor = (advisorId: string, input: ConciergeAdvisorPatch) => + request(`/api/admin/concierge/advisors/${advisorId}`, { method: "PATCH", body: JSON.stringify(input) }); +export const deleteConciergeAdvisor = (advisorId: string) => + request<{ id: string }>(`/api/admin/concierge/advisors/${advisorId}`, { method: "DELETE" }); +export const reorderConciergeAdvisors = (itemIds: string[]) => + request<{ items: ConciergeAdvisor[] }>("/api/admin/concierge/advisors/reorder", { method: "PATCH", body: JSON.stringify({ itemIds }) }); +export const getLeads = (params: { leadType?: LeadType | ""; status?: LeadStatus | ""; keyword?: string; take?: number } = {}) => + request<{ items: Lead[] }>(`/api/admin/leads${buildLeadQuery(params)}`); +export const updateLeadStatus = (leadId: string, status: LeadStatus) => request(`/api/admin/leads/${leadId}/status`, { method: "PATCH", body: JSON.stringify({ status }), }); +export const getMediaAssets = () => request<{ items: MediaAsset[] }>("/api/admin/media-assets"); +export const publishSite = () => request("/api/admin/publish", { method: "POST", body: JSON.stringify({}) }); +export const resetGuizhouContent = () => request("/api/admin/reset-guizhou-content", { method: "POST", body: JSON.stringify({}) }); export const getSystemResource = (resource: string) => request<{ items: unknown[] }>(`/api/admin/system/${resource}`); +export const getSystemUsers = () => request<{ items: SystemUserItem[] }>("/api/admin/system/users"); +export const getSystemRoles = () => request<{ items: SystemRoleItem[] }>("/api/admin/system/roles"); +export const getSystemMenus = () => request<{ items: SystemMenuItem[] }>("/api/admin/system/menus"); +export const getSystemDepts = () => request<{ items: SystemDeptItem[] }>("/api/admin/system/depts"); +export const createSystemUser = (input: SystemUserCreate) => request<{ id: string }>("/api/admin/system/users", { method: "POST", body: JSON.stringify(input) }); +export const updateSystemUser = (userId: string, input: SystemUserPatch) => request<{ id: string }>(`/api/admin/system/users/${userId}`, { method: "PATCH", body: JSON.stringify(input) }); +export const createSystemRole = (input: SystemRoleCreate) => request<{ id: string }>("/api/admin/system/roles", { method: "POST", body: JSON.stringify(input) }); +export const updateSystemRole = (roleId: string, input: SystemRolePatch) => request<{ id: string }>(`/api/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(input) }); +export const createSystemMenu = (input: SystemMenuCreate) => request<{ id: string }>("/api/admin/system/menus", { method: "POST", body: JSON.stringify(input) }); +export const updateSystemMenu = (menuId: string, input: SystemMenuPatch) => request<{ id: string }>(`/api/admin/system/menus/${menuId}`, { method: "PATCH", body: JSON.stringify(input) }); +export const createSystemDept = (input: SystemDeptCreate) => request<{ id: string }>("/api/admin/system/depts", { method: "POST", body: JSON.stringify(input) }); +export const updateSystemDept = (deptId: string, input: SystemDeptPatch) => request<{ id: string }>(`/api/admin/system/depts/${deptId}`, { method: "PATCH", body: JSON.stringify(input) }); diff --git a/WonderQ-Admin-UI-Vue/src/components/concierge/ConciergeAdvisorEditor.vue b/WonderQ-Admin-UI-Vue/src/components/concierge/ConciergeAdvisorEditor.vue new file mode 100644 index 0000000..69eaa83 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/concierge/ConciergeAdvisorEditor.vue @@ -0,0 +1,50 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/concierge/ConciergeDetailEditor.vue b/WonderQ-Admin-UI-Vue/src/components/concierge/ConciergeDetailEditor.vue new file mode 100644 index 0000000..63ec101 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/concierge/ConciergeDetailEditor.vue @@ -0,0 +1,27 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/media/SingleMediaEditor.vue b/WonderQ-Admin-UI-Vue/src/components/media/SingleMediaEditor.vue new file mode 100644 index 0000000..e0f33fc --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/media/SingleMediaEditor.vue @@ -0,0 +1,53 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/site-config/SiteConfigEditor.vue b/WonderQ-Admin-UI-Vue/src/components/site-config/SiteConfigEditor.vue new file mode 100644 index 0000000..81e25a5 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/site-config/SiteConfigEditor.vue @@ -0,0 +1,154 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/system/SystemResourceEditor.vue b/WonderQ-Admin-UI-Vue/src/components/system/SystemResourceEditor.vue new file mode 100644 index 0000000..3907af6 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/system/SystemResourceEditor.vue @@ -0,0 +1,167 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/wanfa/DetailFields.vue b/WonderQ-Admin-UI-Vue/src/components/wanfa/DetailFields.vue new file mode 100644 index 0000000..6acd27c --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/wanfa/DetailFields.vue @@ -0,0 +1,50 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/wanfa/ImageGalleryEditor.vue b/WonderQ-Admin-UI-Vue/src/components/wanfa/ImageGalleryEditor.vue new file mode 100644 index 0000000..1981eed --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/wanfa/ImageGalleryEditor.vue @@ -0,0 +1,64 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaCategoryEditor.vue b/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaCategoryEditor.vue new file mode 100644 index 0000000..e8b978f --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaCategoryEditor.vue @@ -0,0 +1,41 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaRouteEditor.vue b/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaRouteEditor.vue new file mode 100644 index 0000000..299223d --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/wanfa/WanfaRouteEditor.vue @@ -0,0 +1,92 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/layouts/AdminLayout.vue b/WonderQ-Admin-UI-Vue/src/layouts/AdminLayout.vue index 22eec8a..ebc561d 100644 --- a/WonderQ-Admin-UI-Vue/src/layouts/AdminLayout.vue +++ b/WonderQ-Admin-UI-Vue/src/layouts/AdminLayout.vue @@ -4,11 +4,13 @@ import { useRoute, useRouter } from "vue-router"; import { ElMessageBox } from "element-plus"; import { useAuthStore } from "@/stores/auth"; import MenuTree from "@/components/MenuTree.vue"; +import { routeViewKey } from "@/lib/route-view"; const auth = useAuthStore(); const route = useRoute(); const router = useRouter(); const activePath = computed(() => route.path); +const currentViewKey = computed(() => routeViewKey(router.currentRoute.value.fullPath)); async function logout() { await ElMessageBox.confirm("确定退出当前后台会话吗?", "退出登录", { type: "warning" }); @@ -43,7 +45,11 @@ async function logout() { 退出 - + + + + + diff --git a/WonderQ-Admin-UI-Vue/src/lib/concierge.test.ts b/WonderQ-Admin-UI-Vue/src/lib/concierge.test.ts new file mode 100644 index 0000000..7982bf7 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/concierge.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + compactConciergeAdvisor, + createEmptyConciergeAdvisor, + validateConciergeAdvisor, +} from "@/lib/concierge"; + +describe("concierge editor helpers", () => { + it("normalizes advisor fields and removes empty service details", () => { + expect(compactConciergeAdvisor({ + ...createEmptyConciergeAdvisor(), + avatar: " https://example.com/avatar.jpg ", + name: " 示例顾问 ", + role: " SENIOR ADVISOR ", + details: [ + { icon: " calendar ", label: " 服务经验:8年 " }, + { icon: "", label: "" }, + ], + qrImage: " https://example.com/qr.png ", + isActive: false, + })).toEqual({ + avatar: "https://example.com/avatar.jpg", + name: "示例顾问", + role: "SENIOR ADVISOR", + details: [{ icon: "calendar", label: "服务经验:8年" }], + qrImage: "https://example.com/qr.png", + isActive: false, + }); + }); + + it("rejects missing required advisor content before saving", () => { + expect(validateConciergeAdvisor({ ...createEmptyConciergeAdvisor(), name: "" })).toBe("请填写顾问姓名"); + expect(validateConciergeAdvisor({ ...createEmptyConciergeAdvisor(), name: "顾问", role: "Advisor", avatar: "data:image/png;base64,abc", qrImage: "https://example.com/q.png" })).toBe("头像必须是有效的 HTTP(S) URL"); + expect(validateConciergeAdvisor({ ...createEmptyConciergeAdvisor(), name: "顾问", role: "Advisor", avatar: "https://example.com/a.jpg", qrImage: "not-a-url" })).toBe("二维码必须是有效的 HTTP(S) URL"); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/concierge.ts b/WonderQ-Admin-UI-Vue/src/lib/concierge.ts new file mode 100644 index 0000000..30841da --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/concierge.ts @@ -0,0 +1,40 @@ +import type { ConciergeAdvisorCreate, ConciergeDetail } from "@/types"; + +export function createEmptyConciergeAdvisor(): ConciergeAdvisorCreate { + return { avatar: "", name: "", role: "", details: [], qrImage: "", isActive: true }; +} + +export function compactConciergeAdvisor(draft: ConciergeAdvisorCreate): ConciergeAdvisorCreate { + return { + avatar: draft.avatar.trim(), + name: draft.name.trim(), + role: draft.role.trim(), + details: draft.details.map(copyDetail).filter((item) => item.icon || item.label), + qrImage: draft.qrImage.trim(), + isActive: draft.isActive ?? true, + ...(Number.isFinite(Number(draft.sortOrder)) ? { sortOrder: Math.max(0, Number(draft.sortOrder)) } : {}), + }; +} + +export function validateConciergeAdvisor(draft: ConciergeAdvisorCreate): string | null { + const payload = compactConciergeAdvisor(draft); + if (!payload.name) return "请填写顾问姓名"; + if (!payload.role) return "请填写顾问职位"; + if (!isHttpUrl(payload.avatar)) return "头像必须是有效的 HTTP(S) URL"; + if (!isHttpUrl(payload.qrImage)) return "二维码必须是有效的 HTTP(S) URL"; + const invalidDetail = payload.details.find((item) => !item.icon || !item.label); + return invalidDetail ? "服务详情需要填写图标和说明" : null; +} + +function copyDetail(item: ConciergeDetail): ConciergeDetail { + return { icon: item.icon.trim(), label: item.label.trim() }; +} + +function isHttpUrl(value: string) { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} diff --git a/WonderQ-Admin-UI-Vue/src/lib/leads.test.ts b/WonderQ-Admin-UI-Vue/src/lib/leads.test.ts new file mode 100644 index 0000000..4091c20 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/leads.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { buildLeadQuery, leadStatusOptions } from "@/lib/leads"; + +describe("lead filters", () => { + it("builds stable query params for the admin lead contract", () => { + expect(buildLeadQuery({ leadType: "vehicle", status: "planning", keyword: " 贵州 ", take: 50 })).toBe("?leadType=vehicle&status=planning&keyword=%E8%B4%B5%E5%B7%9E&take=50"); + }); + + it("keeps all six backend statuses visible", () => { + expect(leadStatusOptions.map((item) => item.value)).toEqual([ + "new", "assigned", "contacted", "planning", "won", "invalid", + ]); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/leads.ts b/WonderQ-Admin-UI-Vue/src/lib/leads.ts new file mode 100644 index 0000000..956d721 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/leads.ts @@ -0,0 +1,37 @@ +import type { LeadStatus, LeadType } from "@/types"; + +export const leadStatusOptions: Array<{ value: LeadStatus; label: string }> = [ + { value: "new", label: "待处理" }, + { value: "assigned", label: "已分配" }, + { value: "contacted", label: "已联系" }, + { value: "planning", label: "方案沟通" }, + { value: "won", label: "已成交" }, + { value: "invalid", label: "无效" }, +]; + +export const leadTypeOptions: Array<{ value: LeadType | ""; label: string }> = [ + { value: "", label: "全部类型" }, + { value: "vehicle", label: "用车需求" }, + { value: "general", label: "一般需求" }, +]; + +export type LeadQuery = { + leadType?: LeadType | ""; + status?: LeadStatus | ""; + keyword?: string; + take?: number; +}; + +export function buildLeadQuery(params: LeadQuery = {}) { + const query = new URLSearchParams(); + if (params.leadType) query.set("leadType", params.leadType); + if (params.status) query.set("status", params.status); + if (params.keyword?.trim()) query.set("keyword", params.keyword.trim()); + if (params.take) query.set("take", String(params.take)); + const value = query.toString(); + return value ? `?${value}` : ""; +} + +export function leadStatusLabel(status: string) { + return leadStatusOptions.find((item) => item.value === status)?.label ?? status; +} diff --git a/WonderQ-Admin-UI-Vue/src/lib/media.test.ts b/WonderQ-Admin-UI-Vue/src/lib/media.test.ts new file mode 100644 index 0000000..58243cb --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/media.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { validateMediaFile } from "@/lib/media"; + +describe("media upload validation", () => { + it("accepts supported images under the 5MB limit", () => { + expect(validateMediaFile(new File(["image"], "cover.webp", { type: "image/webp" }))).toBeNull(); + }); + + it("rejects unsupported types and oversized files", () => { + expect(validateMediaFile(new File(["text"], "note.txt", { type: "text/plain" }))).toBe("仅支持 JPG、PNG、WebP 或 GIF 图片"); + const oversized = new File([new Uint8Array(5 * 1024 * 1024 + 1)], "large.png", { type: "image/png" }); + expect(validateMediaFile(oversized)).toBe("图片大小不能超过 5MB"); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/media.ts b/WonderQ-Admin-UI-Vue/src/lib/media.ts new file mode 100644 index 0000000..ae4bd7b --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/media.ts @@ -0,0 +1,14 @@ +const MAX_MEDIA_BYTES = 5 * 1024 * 1024; +const ALLOWED_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +export function validateMediaFile(file: File) { + if (!ALLOWED_MEDIA_TYPES.has(file.type)) return "仅支持 JPG、PNG、WebP 或 GIF 图片"; + if (file.size > MAX_MEDIA_BYTES) return "图片大小不能超过 5MB"; + return null; +} + +export function formatMediaSize(sizeBytes?: number | null) { + if (!sizeBytes || sizeBytes < 1024) return `${sizeBytes ?? 0} B`; + if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`; + return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/WonderQ-Admin-UI-Vue/src/lib/menu-routes.test.ts b/WonderQ-Admin-UI-Vue/src/lib/menu-routes.test.ts new file mode 100644 index 0000000..eef7b7a --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/menu-routes.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { isStaticRouteComponent } from "./menu-routes"; + +describe("admin menu route registration", () => { + it("keeps RBAC resource pages on the static parameter route", () => { + expect(isStaticRouteComponent("SystemUsers")).toBe(true); + expect(isStaticRouteComponent("SystemRoles")).toBe(true); + expect(isStaticRouteComponent("SystemMenus")).toBe(true); + expect(isStaticRouteComponent("SystemDepts")).toBe(true); + expect(isStaticRouteComponent("Dashboard")).toBe(false); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/menu-routes.ts b/WonderQ-Admin-UI-Vue/src/lib/menu-routes.ts new file mode 100644 index 0000000..a037740 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/menu-routes.ts @@ -0,0 +1,5 @@ +const staticRouteComponents = new Set(["SystemUsers", "SystemRoles", "SystemMenus", "SystemDepts"]); + +export function isStaticRouteComponent(component: string | null | undefined) { + return typeof component === "string" && staticRouteComponents.has(component); +} diff --git a/WonderQ-Admin-UI-Vue/src/lib/route-view.test.ts b/WonderQ-Admin-UI-Vue/src/lib/route-view.test.ts new file mode 100644 index 0000000..e502e40 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/route-view.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { routeViewKey } from "./route-view"; + +describe("route view key", () => { + it("distinguishes parameterized admin pages by full path", () => { + expect(routeViewKey("/admin/system/users")).not.toBe(routeViewKey("/admin/system/roles")); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/route-view.ts b/WonderQ-Admin-UI-Vue/src/lib/route-view.ts new file mode 100644 index 0000000..02d5252 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/route-view.ts @@ -0,0 +1,3 @@ +export function routeViewKey(fullPath: string) { + return fullPath; +} diff --git a/WonderQ-Admin-UI-Vue/src/lib/site-config.test.ts b/WonderQ-Admin-UI-Vue/src/lib/site-config.test.ts new file mode 100644 index 0000000..b205b7a --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/site-config.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + compactSiteItemPayload, + createEmptySiteItemDraft, + siteModuleDefinitions, + validateSiteItemPayload, +} from "@/lib/site-config"; + +describe("site config helpers", () => { + it("provides the homepage module metadata", () => { + expect(siteModuleDefinitions.map((item) => item.id)).toEqual([ + "heroSlides", + "destinationHero", + "demandHero", + "demandFeatureCards", + "demandForm", + "vehicleOptions", + "vehicleService", + ]); + }); + + it("normalizes a hero slide draft without sending unrelated fields", () => { + const payload = compactSiteItemPayload("heroSlides", { + ...createEmptySiteItemDraft("heroSlides", 2), + title: " 贵州亲子游 ", + kicker: " 热门线路 ", + image: " https://example.com/hero.jpg ", + isActive: true, + }); + + expect(payload).toEqual({ + title: "贵州亲子游", + kicker: "热门线路", + image: "https://example.com/hero.jpg", + isActive: true, + sortOrder: 2, + }); + }); + + it("compacts vehicle service line items and removes blank lines", () => { + const payload = compactSiteItemPayload("vehicleService", { + ...createEmptySiteItemDraft("vehicleService", 0), + introTitle: " 万趣用车 ", + intro: " 专属用车服务 ", + serviceSections: [ + { title: " 接送机 ", description: " 行程衔接 " }, + { title: "", description: "" }, + ], + advantages: [" 车型灵活 ", "", " 管家协助 "], + processSteps: [{ title: " 沟通 ", description: " 确认需求 " }], + }); + + expect(payload).toEqual({ + introTitle: "万趣用车", + intro: "专属用车服务", + serviceSections: [{ title: "接送机", description: "行程衔接" }], + advantages: ["车型灵活", "管家协助"], + processSteps: [{ title: "沟通", description: "确认需求" }], + isActive: true, + }); + }); + + it("rejects non-http image URLs before a write request", () => { + expect(validateSiteItemPayload("heroSlides", { image: "javascript:alert(1)" })).toBe("图片地址必须是有效的 HTTP(S) URL"); + expect(validateSiteItemPayload("heroSlides", { image: "https://example.com/hero.jpg" })).toBeNull(); + }); + + it("normalizes the singleton demand form without a sort field", () => { + expect(compactSiteItemPayload("demandForm", { + ...createEmptySiteItemDraft("demandForm", 0), + destinationLabel: " 去哪里 ", + destinationPlaceholder: " 输入目的地 ", + phoneLabel: " 联系方式 ", + phonePlaceholder: " 手机号 ", + noteLabel: " 补充说明 ", + notePlaceholder: " 行程偏好 ", + submitLabel: " 提交需求 ", + chips: [" 贵州 ", "", " 黄果树 "], + sortOrder: 9, + })).toEqual({ + destinationLabel: "去哪里", + destinationPlaceholder: "输入目的地", + phoneLabel: "联系方式", + phonePlaceholder: "手机号", + noteLabel: "补充说明", + notePlaceholder: "行程偏好", + submitLabel: "提交需求", + chips: ["贵州", "黄果树"], + isActive: true, + }); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/site-config.ts b/WonderQ-Admin-UI-Vue/src/lib/site-config.ts new file mode 100644 index 0000000..602c332 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/site-config.ts @@ -0,0 +1,119 @@ +import type { SiteConfigItem, SiteItemPatch, SiteModule, SiteServiceLine } from "@/types"; + +export type SiteModuleDefinition = { + id: SiteModule; + label: string; + description: string; + primaryField: "title" | "introTitle" | "submitLabel"; + ordered: boolean; + singleton: boolean; +}; + +export const siteModuleDefinitions: SiteModuleDefinition[] = [ + { id: "heroSlides", label: "顶部轮播", description: "维护首页顶部轮播内容和展示顺序。", primaryField: "title", ordered: true, singleton: false }, + { id: "destinationHero", label: "目的地主视觉", description: "维护目的地页主视觉标题和图片。", primaryField: "title", ordered: true, singleton: false }, + { id: "demandHero", label: "需求页主视觉", description: "维护需求页主视觉说明和定制步骤。", primaryField: "title", ordered: true, singleton: false }, + { id: "demandFeatureCards", label: "需求页特色卡片", description: "维护需求页特色卡片标题和描述。", primaryField: "title", ordered: true, singleton: false }, + { id: "demandForm", label: "需求表单", description: "维护需求表单字段、占位文案和快捷选项。", primaryField: "submitLabel", ordered: false, singleton: true }, + { id: "vehicleOptions", label: "万趣用车", description: "维护首页用车卡片内容和展示顺序。", primaryField: "title", ordered: true, singleton: false }, + { id: "vehicleService", label: "用车服务", description: "维护用车服务页的单例说明内容。", primaryField: "introTitle", ordered: false, singleton: true }, +]; + +export function getSiteModuleDefinition(module: SiteModule) { + return siteModuleDefinitions.find((item) => item.id === module) as SiteModuleDefinition; +} + +export function createEmptySiteItemDraft(module: SiteModule, sortOrder: number): SiteItemPatch { + if (module === "heroSlides") return { title: "", kicker: "", image: "", isActive: false, sortOrder }; + if (module === "destinationHero") return { title: "", kicker: "", image: "", isActive: true, sortOrder }; + if (module === "demandHero") return { title: "", kicker: "", description: "", steps: [], isActive: true, sortOrder }; + if (module === "demandFeatureCards") return { title: "", description: "", isActive: true, sortOrder }; + if (module === "demandForm") { + return { + destinationLabel: "目的地/玩法", + destinationPlaceholder: "例如:贵州、黄果树、西江苗寨", + phoneLabel: "联系方式", + phonePlaceholder: "手机号 / 微信号", + noteLabel: "补充说明", + notePlaceholder: "出行日期、人数、酒店偏好、预算范围", + submitLabel: "提交出行需求", + chips: [], + isActive: true, + }; + } + if (module === "vehicleOptions") return { title: "", description: "", image: "", isActive: true, sortOrder }; + return { introTitle: "万趣用车", intro: "旅游出行专属用车服务,管家协助确认合适车型与行程安排。", serviceSections: [], advantages: [], processSteps: [], isActive: true }; +} + +export function siteItemDraft(item: SiteConfigItem): SiteItemPatch { + if ("introTitle" in item) { + return { introTitle: item.introTitle, intro: item.intro, serviceSections: item.serviceSections.map(copyLine), advantages: [...item.advantages], processSteps: item.processSteps.map(copyLine), isActive: item.isActive }; + } + if ("submitLabel" in item) { + return { + destinationLabel: item.destinationLabel, + destinationPlaceholder: item.destinationPlaceholder, + phoneLabel: item.phoneLabel, + phonePlaceholder: item.phonePlaceholder, + noteLabel: item.noteLabel, + notePlaceholder: item.notePlaceholder, + submitLabel: item.submitLabel, + chips: [...item.chips], + isActive: item.isActive, + }; + } + return { + title: item.title, + kicker: "kicker" in item ? item.kicker : undefined, + description: "description" in item ? item.description : undefined, + steps: "steps" in item ? [...item.steps] : undefined, + image: "image" in item ? item.image : undefined, + isActive: item.isActive, + sortOrder: "sortOrder" in item ? item.sortOrder : undefined, + }; +} + +export function compactSiteItemPayload(module: SiteModule, draft: SiteItemPatch): SiteItemPatch { + if (module === "heroSlides" || module === "destinationHero") { + return { title: normalizeText(draft.title), kicker: normalizeNullableText(draft.kicker), image: normalizeUrl(draft.image), isActive: draft.isActive ?? true, sortOrder: normalizeSortOrder(draft.sortOrder) }; + } + if (module === "demandHero") { + return { title: normalizeText(draft.title), kicker: normalizeNullableText(draft.kicker), description: normalizeNullableText(draft.description), steps: normalizeList(draft.steps), isActive: draft.isActive ?? true, sortOrder: normalizeSortOrder(draft.sortOrder) }; + } + if (module === "demandFeatureCards") { + return { title: normalizeText(draft.title), description: normalizeNullableText(draft.description), isActive: draft.isActive ?? true, sortOrder: normalizeSortOrder(draft.sortOrder) }; + } + if (module === "demandForm") { + return { + destinationLabel: normalizeText(draft.destinationLabel), + destinationPlaceholder: normalizeNullableText(draft.destinationPlaceholder), + phoneLabel: normalizeText(draft.phoneLabel), + phonePlaceholder: normalizeNullableText(draft.phonePlaceholder), + noteLabel: normalizeText(draft.noteLabel), + notePlaceholder: normalizeNullableText(draft.notePlaceholder), + submitLabel: normalizeText(draft.submitLabel), + chips: normalizeList(draft.chips), + isActive: draft.isActive ?? true, + }; + } + if (module === "vehicleOptions") { + return { title: normalizeText(draft.title), description: normalizeNullableText(draft.description), image: normalizeUrl(draft.image), isActive: draft.isActive ?? true, sortOrder: normalizeSortOrder(draft.sortOrder) }; + } + return { introTitle: normalizeText(draft.introTitle), intro: normalizeText(draft.intro), serviceSections: normalizeLines(draft.serviceSections), advantages: normalizeList(draft.advantages), processSteps: normalizeLines(draft.processSteps), isActive: draft.isActive ?? true }; +} + +export function validateSiteItemPayload(module: SiteModule, payload: SiteItemPatch) { + if ((module === "heroSlides" || module === "destinationHero" || module === "vehicleOptions") && payload.image) { + if (!isHttpUrl(payload.image)) return "图片地址必须是有效的 HTTP(S) URL"; + } + return null; +} + +function normalizeText(value: string | null | undefined) { return value?.trim() || ""; } +function normalizeNullableText(value: string | null | undefined) { return value?.trim() || null; } +function normalizeUrl(value: string | null | undefined) { return value?.trim() || null; } +function normalizeSortOrder(value: number | undefined) { return Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : undefined; } +function normalizeList(values: string[] | undefined) { return (values ?? []).map((item) => item.trim()).filter(Boolean); } +function normalizeLines(values: SiteServiceLine[] | undefined) { return (values ?? []).map(copyLine).map((item) => ({ title: item.title.trim(), description: item.description.trim() })).filter((item) => item.title || item.description); } +function copyLine(item: SiteServiceLine): SiteServiceLine { return { title: item.title, description: item.description }; } +function isHttpUrl(value: string) { try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } diff --git a/WonderQ-Admin-UI-Vue/src/lib/system-resource.test.ts b/WonderQ-Admin-UI-Vue/src/lib/system-resource.test.ts new file mode 100644 index 0000000..98393a1 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/system-resource.test.ts @@ -0,0 +1,25 @@ +import { nextTick, ref } from "vue"; +import { describe, expect, it } from "vitest"; +import { useSystemResource } from "./system-resource"; + +describe("system resource loader", () => { + it("reloads records when the route resource changes", async () => { + const resource = ref("users"); + const requestedResources: string[] = []; + const state = useSystemResource(resource, async (name) => { + requestedResources.push(name); + return { items: [name] }; + }); + + await nextTick(); + await Promise.resolve(); + expect(requestedResources).toEqual(["users"]); + expect(state.items.value).toEqual(["users"]); + + resource.value = "roles"; + await nextTick(); + await Promise.resolve(); + expect(requestedResources).toEqual(["users", "roles"]); + expect(state.items.value).toEqual(["roles"]); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/system-resource.ts b/WonderQ-Admin-UI-Vue/src/lib/system-resource.ts new file mode 100644 index 0000000..a9bb74d --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/system-resource.ts @@ -0,0 +1,40 @@ +import { ref, watch, type Ref } from "vue"; +import { getSystemResource } from "@/api/client"; + +export type SystemResourceResponse = { items: unknown[] }; +export type SystemResourceFetcher = (resource: string) => Promise; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "系统数据加载失败"; +} + +export function useSystemResource( + resource: Readonly>, + fetcher: SystemResourceFetcher = getSystemResource, +) { + const items = ref([]); + const loading = ref(true); + const error = ref(""); + let requestVersion = 0; + + const load = async () => { + const currentVersion = ++requestVersion; + loading.value = true; + error.value = ""; + try { + const response = await fetcher(resource.value); + if (currentVersion !== requestVersion) return; + items.value = response.items; + } catch (err) { + if (currentVersion !== requestVersion) return; + items.value = []; + error.value = errorMessage(err); + } finally { + if (currentVersion === requestVersion) loading.value = false; + } + }; + + watch(resource, load, { immediate: true }); + + return { items, loading, error, reload: load }; +} diff --git a/WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts b/WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts new file mode 100644 index 0000000..01a5ce0 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/wanfa.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + compactDetail, + compactWanfaRoute, + createEmptyDetail, + createEmptyWanfaRoute, +} from "@/lib/wanfa"; + +describe("wanfa editor helpers", () => { + it("normalizes a route draft to the API contract", () => { + expect(compactWanfaRoute({ + ...createEmptyWanfaRoute(), + title: " 亲子玩水 ", + subtitle: " 贵州·轻松节奏 ", + image: " https://example.com/route.jpg ", + routeCount: 4, + demandKeyword: " 亲子 ", + })).toEqual({ + title: "亲子玩水", + subtitle: "贵州·轻松节奏", + image: "https://example.com/route.jpg", + routeCount: 4, + demandKeyword: "亲子", + }); + }); + + it("normalizes detail line fields and keeps empty lists out", () => { + expect(compactDetail({ + ...createEmptyDetail(), + key: " route-1 ", + eyebrow: " 玩法路线 ", + duration: " 5天4晚 ", + title: " 贵州亲子玩水 ", + subtitle: " 山地与自然课堂 ", + intro: " 适合家庭出行。 ", + highlights: [" 亲子互动 ", ""], + included: [" 用车 ", " "], + excluded: [], + notes: [" 雨具 ", ""], + gallery: [" https://example.com/detail.jpg "], + conciergeAdvisorId: " advisor-1 ", + isActive: true, + })).toEqual({ + key: "route-1", + eyebrow: "玩法路线", + duration: "5天4晚", + title: "贵州亲子玩水", + subtitle: "山地与自然课堂", + intro: "适合家庭出行。", + highlights: ["亲子互动"], + included: ["用车"], + excluded: [], + notes: ["雨具"], + gallery: ["https://example.com/detail.jpg"], + conciergeAdvisorId: "advisor-1", + isActive: true, + }); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/wanfa.ts b/WonderQ-Admin-UI-Vue/src/lib/wanfa.ts new file mode 100644 index 0000000..90e523c --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/wanfa.ts @@ -0,0 +1,80 @@ +import type { DetailCreate, WanfaRouteCreate } from "@/types"; + +export function createEmptyWanfaRoute(): WanfaRouteCreate { + return { title: "", subtitle: "", image: "", routeCount: 0, demandKeyword: "" }; +} + +export function compactWanfaRoute(draft: WanfaRouteCreate): WanfaRouteCreate { + return { + title: draft.title.trim(), + subtitle: draft.subtitle.trim(), + image: draft.image.trim(), + routeCount: Number.isFinite(Number(draft.routeCount)) ? Math.max(0, Number(draft.routeCount)) : 0, + demandKeyword: draft.demandKeyword.trim(), + }; +} + +export function createEmptyDetail(): DetailCreate { + return { + key: "", + eyebrow: "玩法路线", + duration: "", + title: "", + subtitle: "", + intro: "", + highlights: [], + included: [], + excluded: [], + notes: [], + gallery: [], + conciergeAdvisorId: null, + isActive: true, + }; +} + +export function compactDetail(draft: DetailCreate): DetailCreate { + return { + key: draft.key.trim(), + eyebrow: draft.eyebrow.trim(), + duration: draft.duration.trim(), + title: draft.title.trim(), + subtitle: draft.subtitle.trim(), + intro: draft.intro.trim(), + highlights: compactList(draft.highlights), + included: compactList(draft.included), + excluded: compactList(draft.excluded), + notes: compactList(draft.notes), + gallery: compactList(draft.gallery), + conciergeAdvisorId: draft.conciergeAdvisorId?.trim() || null, + isActive: draft.isActive ?? true, + sortOrder: draft.sortOrder, + }; +} + +export function validateWanfaWrite(route: WanfaRouteCreate, detail: DetailCreate) { + if (!route.title || !route.subtitle || !route.image || !route.demandKeyword) { + return "请完整填写路线标题、副标题、封面和需求关键词。"; + } + if (!isHttpUrl(route.image)) return "路线封面必须是有效的 HTTP(S) URL。"; + if (!Number.isInteger(route.routeCount) || route.routeCount < 0) { + return "路线数量必须是大于等于 0 的整数。"; + } + if (!detail.eyebrow || !detail.duration || !detail.title || !detail.subtitle || !detail.intro) { + return "请完整填写详情眉标、时长、标题、副标题和详情介绍。"; + } + if (detail.gallery.some((image) => !isHttpUrl(image))) return "详情图片必须是有效的 HTTP(S) URL。"; + return null; +} + +function compactList(items: string[]) { + return items.map((item) => item.trim()).filter(Boolean); +} + +function isHttpUrl(value: string) { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} diff --git a/WonderQ-Admin-UI-Vue/src/pages/ConciergePage.vue b/WonderQ-Admin-UI-Vue/src/pages/ConciergePage.vue index d521682..c7fa87e 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/ConciergePage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/ConciergePage.vue @@ -1,10 +1,120 @@ - + diff --git a/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue b/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue index 96c06f3..be70a2f 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/HomePage.vue @@ -1,14 +1,270 @@ diff --git a/WonderQ-Admin-UI-Vue/src/pages/LeadsPage.test.ts b/WonderQ-Admin-UI-Vue/src/pages/LeadsPage.test.ts new file mode 100644 index 0000000..39bb79a --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/pages/LeadsPage.test.ts @@ -0,0 +1,11 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("LeadsPage initial loading", () => { + it("loads leads when the page is mounted", async () => { + const source = await readFile(fileURLToPath(new URL("./LeadsPage.vue", import.meta.url)), "utf8"); + + expect(source).toMatch(/onMounted\(load\)/); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/pages/LeadsPage.vue b/WonderQ-Admin-UI-Vue/src/pages/LeadsPage.vue index 3b29a23..306389f 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/LeadsPage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/LeadsPage.vue @@ -1,29 +1,71 @@ - + diff --git a/WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue b/WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue new file mode 100644 index 0000000..07855cd --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue @@ -0,0 +1,65 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/pages/OperationsToolsPage.vue b/WonderQ-Admin-UI-Vue/src/pages/OperationsToolsPage.vue new file mode 100644 index 0000000..f008ce7 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/pages/OperationsToolsPage.vue @@ -0,0 +1,52 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/pages/SystemResourcePage.vue b/WonderQ-Admin-UI-Vue/src/pages/SystemResourcePage.vue index c009361..a09b11f 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/SystemResourcePage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/SystemResourcePage.vue @@ -1,12 +1,143 @@ - + diff --git a/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue b/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue index dcc7f34..edc82ef 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/WanfaPage.vue @@ -1,10 +1,283 @@ - + diff --git a/WonderQ-Admin-UI-Vue/src/router/index.ts b/WonderQ-Admin-UI-Vue/src/router/index.ts index 65c9b44..a6ca2b3 100644 --- a/WonderQ-Admin-UI-Vue/src/router/index.ts +++ b/WonderQ-Admin-UI-Vue/src/router/index.ts @@ -7,8 +7,12 @@ import HomePage from "@/pages/HomePage.vue"; import WanfaPage from "@/pages/WanfaPage.vue"; import ConciergePage from "@/pages/ConciergePage.vue"; import LeadsPage from "@/pages/LeadsPage.vue"; +import MediaPage from "@/pages/MediaPage.vue"; +import OperationsToolsPage from "@/pages/OperationsToolsPage.vue"; import SystemResourcePage from "@/pages/SystemResourcePage.vue"; import NotFoundPage from "@/pages/NotFoundPage.vue"; +import { isStaticRouteComponent } from "@/lib/menu-routes"; +import { adminIndexRoute } from "./route-config"; const componentRegistry = { Dashboard: DashboardPage, @@ -16,6 +20,8 @@ const componentRegistry = { WanfaPage, ConciergePage, LeadsPage, + MediaPage, + OperationsToolsPage, SystemUsers: SystemResourcePage, SystemRoles: SystemResourcePage, SystemMenus: SystemResourcePage, @@ -36,8 +42,10 @@ const router = createRouter({ { path: "play", name: "play", component: WanfaPage, meta: { permission: "admin:wanfa:read" } }, { path: "concierge", name: "concierge", component: ConciergePage, meta: { permission: "admin:concierge:read" } }, { path: "leads", name: "leads", component: LeadsPage, meta: { permission: "admin:leads:read" } }, + { path: "media", name: "media", component: MediaPage, meta: { permission: "admin:media:read" } }, + { path: "tools", name: "operations-tools", component: OperationsToolsPage, meta: { permission: "admin:site-config" } }, { path: "system/:resource", name: "system-resource", component: SystemResourcePage }, - { path: "", redirect: "/dashboard" }, + adminIndexRoute, ], }, { path: "/:pathMatch(.*)*", name: "not-found", component: NotFoundPage }, @@ -50,7 +58,7 @@ function registerDynamicRoutes() { if (dynamicRoutesRegistered) return; const auth = useAuthStore(); for (const menu of auth.menus) { - if (menu.type !== "page" || !menu.path || !menu.component || !(menu.component in componentRegistry)) continue; + if (menu.type !== "page" || !menu.path || !menu.component || isStaticRouteComponent(menu.component) || !(menu.component in componentRegistry)) continue; const path = menu.path.replace(/^\/+/, ""); if (router.getRoutes().some((route) => route.path === `/${path}`)) continue; router.addRoute("admin", { diff --git a/WonderQ-Admin-UI-Vue/src/router/route-config.ts b/WonderQ-Admin-UI-Vue/src/router/route-config.ts new file mode 100644 index 0000000..acc52c7 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/router/route-config.ts @@ -0,0 +1,5 @@ +export const adminIndexRoute = { + path: "", + name: "admin-index", + redirect: "/dashboard", +} as const; diff --git a/WonderQ-Admin-UI-Vue/src/router/router.test.ts b/WonderQ-Admin-UI-Vue/src/router/router.test.ts new file mode 100644 index 0000000..3db8e97 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/router/router.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { adminIndexRoute } from "./route-config"; + +describe("admin router", () => { + it("names the empty admin child route", () => { + expect(adminIndexRoute.name).toBe("admin-index"); + expect(adminIndexRoute.redirect).toBe("/dashboard"); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/styles.css b/WonderQ-Admin-UI-Vue/src/styles.css index 0e6bfef..6eb8bea 100644 --- a/WonderQ-Admin-UI-Vue/src/styles.css +++ b/WonderQ-Admin-UI-Vue/src/styles.css @@ -31,6 +31,7 @@ button, input { font: inherit; } .admin-main { padding: 34px 40px 54px; } .page-stack { max-width: 1220px; margin: 0 auto; } .page-intro { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 26px; } +.page-intro-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } .page-intro p:not(.eyebrow) { margin: 9px 0 0; color: var(--muted); font-size: 13px; } .metric-grid, .module-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 20px; } .metric-card, .module-card, .panel-card, .advisor-card { border: 1px solid var(--line); border-radius: 12px; } @@ -53,7 +54,97 @@ button, input { font: inherit; } .advisor-card strong, .advisor-card span { display: block; } .advisor-card span { margin-top: 4px; color: var(--muted); font-size: 12px; } .advisor-avatar { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; color: #1d4ed8; background: #dbeafe; font-weight: 700; } +.concierge-list { display: grid; gap: 10px; } +.concierge-row { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 13px 0; border-bottom: 1px solid #f1f5f9; } +.concierge-row:last-child { border-bottom: 0; } +.concierge-summary, .concierge-actions, .media-toolbar, .single-media-heading, .subsection-heading { display: flex; align-items: center; gap: 12px; } +.concierge-summary { min-width: 0; } +.concierge-summary > div:last-child { min-width: 0; } +.concierge-summary strong, .concierge-summary span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.concierge-summary span, .subsection-heading span { margin-top: 4px; color: var(--muted); font-size: 12px; } +.concierge-actions { flex-wrap: wrap; justify-content: flex-end; } +.concierge-editor, .single-media-editor { padding: 4px 4px 14px; } +.concierge-detail-editor { margin: 20px 0; padding-top: 20px; border-top: 1px solid var(--line); } +.subsection-heading { justify-content: space-between; margin-bottom: 12px; } +.subsection-heading > div { display: grid; gap: 2px; } +.concierge-detail-row { display: grid; grid-template-columns: 150px minmax(0, 1fr) auto; gap: 8px; margin-bottom: 8px; } +.single-media-editor { display: grid; align-content: start; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 9px; } +.single-media-heading { justify-content: space-between; } +.single-media-preview { width: 100%; height: 150px; border-radius: 8px; background: #f1f5f9; } +.lead-filter-card { margin-bottom: 16px; } +.lead-filters { display: grid; grid-template-columns: 170px 170px minmax(180px, 1fr) auto; gap: 10px; } +.lead-summary { display: grid; gap: 4px; min-width: 0; } +.lead-summary span { overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.lead-detail-card { margin-top: 16px; } +.lead-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; } +.lead-detail-grid dt { color: var(--muted); font-size: 12px; } +.lead-detail-grid dd { margin: 5px 0 0; color: var(--ink); font-weight: 650; } +.lead-detail-section { margin-top: 20px; padding: 14px; border-radius: 9px; background: #f8fafc; } +.lead-detail-section p { margin: 7px 0 0; color: #475569; line-height: 1.6; } +.media-toolbar { justify-content: flex-end; } +.media-toolbar .el-input { width: 150px; } +.media-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.media-card { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: #fff; } +.media-card > .el-image { display: block; width: 100%; height: 150px; background: #f1f5f9; } +.media-card-content { display: grid; gap: 5px; padding: 10px; } +.media-card-content span, .media-card-content small { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.media-card-content small { color: #94a3b8; } +.operations-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 18px; } +.operation-card h3 { margin: 0; font-size: 19px; } +.operation-card > p:not(.eyebrow) { min-height: 50px; margin: 10px 0 20px; color: var(--muted); line-height: 1.7; } +.danger-operation { border-color: #fecaca; } +.operation-result { margin-top: 18px; } .json-preview { max-height: 220px; overflow: auto; margin: 0; color: #475569; font-size: 12px; white-space: pre-wrap; } +.site-config-intro { align-items: center; } +.site-config-intro + .el-alert { margin-bottom: 18px; } +.site-config-tabs { margin-top: 18px; } +.site-config-panel { padding: 4px 0 0; } +.site-config-panel .panel-heading { margin-bottom: 18px; } +.site-config-panel .panel-heading > div { display: grid; gap: 5px; } +.site-config-panel .panel-heading span { color: var(--muted); font-size: 12px; font-weight: 400; } +.site-item-content { display: flex; align-items: center; gap: 12px; min-width: 0; } +.site-item-content > div { min-width: 0; } +.site-item-content strong, .site-item-content small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.site-item-content small { margin-top: 5px; color: var(--muted); font-size: 12px; } +.site-item-thumb { flex: 0 0 auto; width: 56px; height: 40px; border-radius: 7px; background: #f1f5f9; } +.site-config-editor { padding: 4px 4px 14px; } +.wanfa-workspace { display: grid; grid-template-columns: minmax(260px, .34fr) minmax(0, .66fr); gap: 18px; align-items: start; } +.wanfa-category-panel, .wanfa-route-panel { min-width: 0; } +.wanfa-category-list { display: grid; gap: 8px; } +.wanfa-category-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 7px; padding: 10px; border: 1px solid transparent; border-radius: 9px; background: #f8fafc; } +.wanfa-category-row.is-selected { border-color: #bfdbfe; background: #eff6ff; } +.wanfa-category-select { display: flex; justify-content: space-between; gap: 8px; width: 100%; padding: 0; border: 0; color: var(--ink); background: transparent; text-align: left; cursor: pointer; } +.wanfa-category-select span { color: var(--muted); font-size: 12px; white-space: nowrap; } +.wanfa-row-actions { display: flex; flex-wrap: wrap; gap: 2px; } +.wanfa-row-actions .el-button { margin-left: 0; } +.wanfa-editor { padding: 4px 4px 14px; } +.route-editor { padding-bottom: 0; } +.detail-fields { margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--line); } +.detail-fields-heading { display: grid; gap: 4px; margin-bottom: 18px; } +.detail-fields-heading span { color: var(--muted); font-size: 12px; line-height: 1.5; } +.route-editor .el-alert { margin-top: 18px; } +.gallery-editor { display: grid; gap: 12px; margin-bottom: 18px; padding-top: 4px; } +.gallery-editor-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; } +.gallery-editor-heading > div { display: grid; gap: 4px; } +.gallery-editor-heading span { color: var(--muted); font-size: 12px; } +.media-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } +.gallery-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.gallery-item { min-width: 0; padding: 8px; border: 1px solid var(--line); border-radius: 9px; background: #fff; } +.gallery-item .el-image { display: block; width: 100%; height: 120px; border-radius: 6px; background: #f1f5f9; } +.gallery-item small { display: block; overflow: hidden; margin-top: 7px; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.gallery-actions { display: flex; gap: 2px; margin-top: 3px; } +.editor-heading { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 24px; } +.editor-heading h3 { margin: 0; font-size: 21px; } +.editor-heading p:not(.eyebrow) { margin: 7px 0 0; color: var(--muted); font-size: 13px; line-height: 1.6; } +.site-config-editor .el-form-item { margin-bottom: 18px; } +.field-hint { margin: 6px 0 0; color: #94a3b8; font-size: 12px; line-height: 1.5; } +.site-image-preview { display: block; width: 100%; max-width: 260px; height: 132px; margin-top: 12px; border-radius: 9px; background: #f1f5f9; } +.editor-options { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 10px; } +@media (max-width: 880px) { .wanfa-workspace { grid-template-columns: 1fr; } } +@media (max-width: 980px) { .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .lead-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 760px) { .concierge-row { align-items: flex-start; flex-direction: column; } .concierge-actions { justify-content: flex-start; } .lead-filters { grid-template-columns: 1fr 1fr; } .lead-filters .el-input { grid-column: 1 / -1; } .operations-grid { grid-template-columns: 1fr; } .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 640px) { .site-config-intro { align-items: flex-start; } .site-config-intro .el-button, .page-intro-actions { width: 100%; } .page-intro-actions { justify-content: stretch; } .page-intro-actions .el-button { flex: 1; } .editor-options { grid-template-columns: 1fr; gap: 0; } .site-config-panel .panel-heading { align-items: flex-start; flex-direction: column; } .site-config-panel .panel-heading .el-button { width: 100%; } .concierge-detail-row { grid-template-columns: 1fr; } .lead-filters { grid-template-columns: 1fr; } .lead-filters .el-input { grid-column: auto; } .lead-detail-grid { grid-template-columns: 1fr; } .media-toolbar { align-items: stretch; flex-direction: column; width: 100%; } .media-toolbar .el-input { width: 100%; } .media-grid { grid-template-columns: 1fr; } } .login-page { display: grid; grid-template-columns: minmax(0, 1fr) 420px; min-height: 100vh; padding: 5vw 9vw; gap: 8vw; align-items: center; background: #f8fafc; } .login-art { max-width: 560px; } .login-art h1 { margin: 18px 0; color: #172554; font-size: clamp(34px, 4vw, 61px); letter-spacing: -.05em; line-height: 1.07; } diff --git a/WonderQ-Admin-UI-Vue/src/types.ts b/WonderQ-Admin-UI-Vue/src/types.ts index 9fda32f..1dc1dae 100644 --- a/WonderQ-Admin-UI-Vue/src/types.ts +++ b/WonderQ-Admin-UI-Vue/src/types.ts @@ -43,6 +43,69 @@ export type AdminProfile = { dataScopes: string[]; }; +export type SystemUserItem = AdminUser & { + roleIds: string[]; + deptIds: string[]; + createdAt?: string; + updatedAt?: string; +}; + +export type SystemRoleItem = { + id: string; + name: string; + code: string; + dataScope: "all" | "dept" | "dept_and_children" | "custom_dept" | "self"; + description?: string | null; + isActive: boolean; + menuIds: string[]; + deptIds: string[]; + createdAt?: string; + updatedAt?: string; +}; + +export type SystemMenuItem = { + id: string; + parentId?: string | null; + name: string; + type: "directory" | "page" | "button"; + path?: string | null; + component?: string | null; + permission?: string | null; + icon?: string | null; + sortOrder: number; + isVisible: boolean; + isActive: boolean; + createdAt?: string; + updatedAt?: string; +}; + +export type SystemDeptItem = { + id: string; + parentId?: string | null; + name: string; + sortOrder: number; + isActive: boolean; + createdAt?: string; + updatedAt?: string; +}; + +export type SystemUserCreate = { + email: string; + name: string; + password: string; + isActive: boolean; + roleIds: string[]; + deptIds: string[]; +}; + +export type SystemUserPatch = Partial>; +export type SystemRoleCreate = Omit; +export type SystemRolePatch = Partial; +export type SystemMenuCreate = Omit; +export type SystemMenuPatch = Partial; +export type SystemDeptCreate = Omit; +export type SystemDeptPatch = Partial; + export type AuthPayload = { token: string; accessToken: string; @@ -57,25 +120,255 @@ export type Dashboard = { export type Lead = { id: string; + leadType: LeadType; contactName?: string | null; + customerId?: string | null; phone: string; destination?: string | null; - status: string; - leadType?: string; + travelDate?: string | null; + peopleCount?: number | null; + budgetMin?: number | null; + budgetMax?: number | null; + note?: string | null; + sourcePage?: string | null; + vehicleDemand?: VehicleDemand | null; + status: LeadStatus; + assignedUser?: { id: string; name: string } | null; createdAt?: string; + updatedAt?: string; +}; + +export type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "invalid"; +export type LeadType = "general" | "vehicle"; +export type VehicleDemand = { + serviceType: "charter" | "transfer"; + charterDuration?: "halfDay" | "fullDay" | null; + travelDate: string; + pickupTime?: string | null; + pickupLocation: string; + dropoffLocation: string; + peopleCount: number; + luggageCount?: number | null; + vehicleOptionId?: string | null; + vehicleOptionTitle?: string | null; + specialRequirements?: string | null; }; export type WanfaCategory = { id: string; label: string; - routes: Array<{ id: string; title: string; subtitle: string; routeCount: number }>; + routes: WanfaRoute[]; +}; + +export type WanfaRoute = { + id: string; + title: string; + subtitle: string; + image: string; + routeCount: number; + demandKeyword: string; +}; + +export type WanfaCategoryCreate = { label: string }; +export type WanfaCategoryPatch = { label?: string }; +export type WanfaRouteCreate = { + title: string; + subtitle: string; + image: string; + routeCount: number; + demandKeyword: string; +}; +export type WanfaRoutePatch = Partial; + +export type DetailRecord = { + id: string; + key: string; + eyebrow: string; + duration: string; + title: string; + subtitle: string; + intro: string; + highlights: string[]; + included: string[]; + excluded: string[]; + notes: string[]; + gallery: string[]; + conciergeAdvisorId: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +export type DetailCreate = Omit & { sortOrder?: number }; +export type DetailPatch = Partial; + +export type MediaAsset = { + id: string; + url: string; + name: string; + mimeType: string; + sizeBytes: number; + group: string; + createdAt?: string; + updatedAt?: string; }; export type ConciergeAdvisor = { id: string; + avatar?: string; name: string; role: string; + details?: Array<{ icon: string; label: string }>; + qrImage?: string; isActive: boolean; + sortOrder?: number; + createdAt?: string; + updatedAt?: string; }; -export type SiteConfig = Record; +export type ConciergeDetail = { icon: string; label: string }; +export type ConciergeAdvisorCreate = { + avatar: string; + name: string; + role: string; + details: ConciergeDetail[]; + qrImage: string; + isActive?: boolean; + sortOrder?: number; +}; +export type ConciergeAdvisorPatch = Partial; + +export type SiteVersion = { + id: string; + title: string; + status: string; + snapshot?: unknown; + publishedAt?: string | null; + createdAt?: string; +}; + +export type ResetContentResult = { + heroSlides?: number; + destinationHero?: number; + vehicleOptions?: number; + demandHero?: number; + demandFeatureCards?: number; + demandForm?: number; + siteVersionId?: string; +}; + +export type SiteModule = "heroSlides" | "destinationHero" | "demandHero" | "demandFeatureCards" | "demandForm" | "vehicleOptions" | "vehicleService"; + +export type SiteServiceLine = { + title: string; + description: string; +}; + +export type HeroSlide = { + id: string; + title: string; + kicker: string | null; + image: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +export type DestinationHero = HeroSlide; + +export type DemandHero = { + id: string; + title: string; + kicker: string | null; + description: string | null; + steps: string[]; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +export type DemandFeatureCard = { + id: string; + title: string; + description: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +export type DemandForm = { + id: string; + destinationLabel: string; + destinationPlaceholder: string | null; + phoneLabel: string; + phonePlaceholder: string | null; + noteLabel: string; + notePlaceholder: string | null; + submitLabel: string; + chips: string[]; + isActive: boolean; + createdAt?: string; + updatedAt?: string; +}; + +export type VehicleOption = { + id: string; + title: string; + description: string | null; + image: string | null; + isActive: boolean; + sortOrder: number; + createdAt?: string; + updatedAt?: string; +}; + +export type VehicleServiceConfig = { + id: string; + introTitle: string; + intro: string; + serviceSections: SiteServiceLine[]; + advantages: string[]; + processSteps: SiteServiceLine[]; + isActive: boolean; + createdAt?: string; + updatedAt?: string; +}; + +export type SiteConfig = { + heroSlides: HeroSlide[]; + destinationHero: DestinationHero[]; + demandHero: DemandHero[]; + demandFeatureCards: DemandFeatureCard[]; + demandForm: DemandForm[]; + vehicleOptions: VehicleOption[]; + vehicleService: VehicleServiceConfig[]; +}; + +export type SiteConfigItem = HeroSlide | DestinationHero | DemandHero | DemandFeatureCard | DemandForm | VehicleOption | VehicleServiceConfig; + +export type SiteItemPatch = { + title?: string | null; + kicker?: string | null; + image?: string | null; + description?: string | null; + steps?: string[]; + destinationLabel?: string | null; + destinationPlaceholder?: string | null; + phoneLabel?: string | null; + phonePlaceholder?: string | null; + noteLabel?: string | null; + notePlaceholder?: string | null; + submitLabel?: string | null; + chips?: string[]; + introTitle?: string | null; + intro?: string | null; + serviceSections?: SiteServiceLine[]; + advantages?: string[]; + processSteps?: SiteServiceLine[]; + isActive?: boolean; + sortOrder?: number; +}; diff --git a/WonderQ-Admin/alembic/versions/0002_add_map_image.py b/WonderQ-Admin/alembic/versions/0002_add_map_image.py deleted file mode 100644 index 02f413a..0000000 --- a/WonderQ-Admin/alembic/versions/0002_add_map_image.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Add map image module table. - -Revision ID: 0002_add_map_image -Revises: 0001_initial_schema -Create Date: 2026-07-01 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect - - -revision = "0002_add_map_image" -down_revision = "0001_initial_schema" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - if "MapImage" not in set(inspect(bind).get_table_names()): - op.create_table( - "MapImage", - sa.Column("id", sa.String(), nullable=False), - sa.Column("image", sa.String(), nullable=False), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - bind = op.get_bind() - if "MapImage" in set(inspect(bind).get_table_names()): - op.drop_table("MapImage") diff --git a/WonderQ-Admin/alembic/versions/0003_campaign_display_fields.py b/WonderQ-Admin/alembic/versions/0003_campaign_display_fields.py deleted file mode 100644 index 7537166..0000000 --- a/WonderQ-Admin/alembic/versions/0003_campaign_display_fields.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Add campaign display fields. - -Revision ID: 0003_campaign_display_fields -Revises: 0002_add_map_image -Create Date: 2026-07-02 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect -from sqlalchemy.dialects import postgresql - - -revision = "0003_campaign_display_fields" -down_revision = "0002_add_map_image" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - if "Campaign" not in set(inspect(bind).get_table_names()): - return - - columns = {column["name"] for column in inspect(bind).get_columns("Campaign")} - if "priceAmount" not in columns: - op.add_column("Campaign", sa.Column("priceAmount", sa.Integer(), nullable=True)) - if "priceUnit" not in columns: - op.add_column("Campaign", sa.Column("priceUnit", sa.String(), nullable=True)) - if "tags" not in columns: - op.add_column( - "Campaign", - sa.Column( - "tags", - postgresql.ARRAY(sa.String()), - nullable=False, - server_default=sa.text("'{}'::varchar[]"), - ), - ) - - -def downgrade() -> None: - bind = op.get_bind() - if "Campaign" not in set(inspect(bind).get_table_names()): - return - - columns = {column["name"] for column in inspect(bind).get_columns("Campaign")} - if "tags" in columns: - op.drop_column("Campaign", "tags") - if "priceUnit" in columns: - op.drop_column("Campaign", "priceUnit") - if "priceAmount" in columns: - op.drop_column("Campaign", "priceAmount") diff --git a/WonderQ-Admin/alembic/versions/0004_route_sections.py b/WonderQ-Admin/alembic/versions/0004_route_sections.py deleted file mode 100644 index 21869e5..0000000 --- a/WonderQ-Admin/alembic/versions/0004_route_sections.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Add route section configuration. - -Revision ID: 0004_route_sections -Revises: 0003_campaign_display_fields -Create Date: 2026-07-02 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect - - -revision = "0004_route_sections" -down_revision = "0003_campaign_display_fields" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - - if "RouteSection" not in existing_tables: - op.create_table( - "RouteSection", - sa.Column("id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("subtitle", sa.Text(), nullable=True), - sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - - -def downgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - if "RouteSection" in existing_tables: - op.drop_table("RouteSection") diff --git a/WonderQ-Admin/alembic/versions/0005_hotel_vehicle_modules.py b/WonderQ-Admin/alembic/versions/0005_hotel_vehicle_modules.py deleted file mode 100644 index bc5601c..0000000 --- a/WonderQ-Admin/alembic/versions/0005_hotel_vehicle_modules.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Add hotel and vehicle site modules. - -Revision ID: 0005_hotel_vehicle_modules -Revises: 0004_route_sections -Create Date: 2026-07-03 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect - - -revision = "0005_hotel_vehicle_modules" -down_revision = "0004_route_sections" -branch_labels = None -depends_on = None - - -def _create_card_table(table_name: str) -> None: - op.create_table( - table_name, - sa.Column("id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("image", sa.String(), nullable=True), - sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - -def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - if "HotelGroup" not in existing_tables: - _create_card_table("HotelGroup") - if "VehicleOption" not in existing_tables: - _create_card_table("VehicleOption") - - -def downgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - if "VehicleOption" in existing_tables: - op.drop_table("VehicleOption") - if "HotelGroup" in existing_tables: - op.drop_table("HotelGroup") \ No newline at end of file diff --git a/WonderQ-Admin/alembic/versions/0006_hotel_group_offer_fields.py b/WonderQ-Admin/alembic/versions/0006_hotel_group_offer_fields.py deleted file mode 100644 index 28627b2..0000000 --- a/WonderQ-Admin/alembic/versions/0006_hotel_group_offer_fields.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Add offer display fields to hotel groups. - -Revision ID: 0006_hotel_group_offer_fields -Revises: 0005_hotel_vehicle_modules -Create Date: 2026-07-03 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect -from sqlalchemy.dialects import postgresql - - -revision = "0006_hotel_group_offer_fields" -down_revision = "0005_hotel_vehicle_modules" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - if "HotelGroup" not in set(inspect(bind).get_table_names()): - return - - columns = {column["name"] for column in inspect(bind).get_columns("HotelGroup")} - if "coverImage" not in columns: - op.add_column("HotelGroup", sa.Column("coverImage", sa.String(), nullable=True)) - op.execute('UPDATE "HotelGroup" SET "coverImage" = image WHERE "coverImage" IS NULL') - if "priceAmount" not in columns: - op.add_column("HotelGroup", sa.Column("priceAmount", sa.Integer(), nullable=True)) - if "priceUnit" not in columns: - op.add_column("HotelGroup", sa.Column("priceUnit", sa.String(), nullable=True, server_default="起/晚")) - if "tags" not in columns: - op.add_column( - "HotelGroup", - sa.Column( - "tags", - postgresql.ARRAY(sa.String()), - nullable=False, - server_default=sa.text("'{}'::varchar[]"), - ), - ) - if "status" not in columns: - op.add_column("HotelGroup", sa.Column("status", sa.String(), nullable=False, server_default="published")) - - -def downgrade() -> None: - bind = op.get_bind() - if "HotelGroup" not in set(inspect(bind).get_table_names()): - return - - columns = {column["name"] for column in inspect(bind).get_columns("HotelGroup")} - for column_name in ["status", "tags", "priceUnit", "priceAmount", "coverImage"]: - if column_name in columns: - op.drop_column("HotelGroup", column_name) \ No newline at end of file diff --git a/WonderQ-Admin/alembic/versions/0007_destination_page_modules.py b/WonderQ-Admin/alembic/versions/0007_destination_page_modules.py deleted file mode 100644 index b34295f..0000000 --- a/WonderQ-Admin/alembic/versions/0007_destination_page_modules.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Add destination page configurable modules. - -Revision ID: 0007_destination_page_modules -Revises: 0006_hotel_group_offer_fields -Create Date: 2026-07-07 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect - - -revision = "0007_destination_page_modules" -down_revision = "0006_hotel_group_offer_fields" -branch_labels = None -depends_on = None - - -def _create_destination_hero_table() -> None: - op.create_table( - "DestinationHero", - sa.Column("id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("kicker", sa.String(), nullable=True), - sa.Column("image", sa.String(), nullable=True), - sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - -def _create_destination_region_table() -> None: - op.create_table( - "DestinationRegion", - sa.Column("id", sa.String(), nullable=False), - sa.Column("label", sa.String(), nullable=False), - sa.Column("keyword", sa.String(), nullable=True), - sa.Column("spots", sa.Text(), nullable=True), - sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - -def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - if "DestinationHero" not in existing_tables: - _create_destination_hero_table() - if "DestinationRegion" not in existing_tables: - _create_destination_region_table() - - -def downgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - if "DestinationRegion" in existing_tables: - op.drop_table("DestinationRegion") - if "DestinationHero" in existing_tables: - op.drop_table("DestinationHero") diff --git a/WonderQ-Admin/alembic/versions/0008_demand_page_modules.py b/WonderQ-Admin/alembic/versions/0008_demand_page_modules.py deleted file mode 100644 index eb7375a..0000000 --- a/WonderQ-Admin/alembic/versions/0008_demand_page_modules.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Add demand page configurable modules. - -Revision ID: 0008_demand_page_modules -Revises: 0007_destination_page_modules -Create Date: 2026-07-07 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect -from sqlalchemy.dialects import postgresql - - -revision = "0008_demand_page_modules" -down_revision = "0007_destination_page_modules" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - - if "DemandHero" not in existing_tables: - op.create_table( - "DemandHero", - sa.Column("id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("kicker", sa.String(), nullable=True), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("steps", postgresql.ARRAY(sa.String()), nullable=False, server_default=sa.text("'{}'")), - sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - if "DemandFeatureCard" not in existing_tables: - op.create_table( - "DemandFeatureCard", - sa.Column("id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - if "DemandForm" not in existing_tables: - op.create_table( - "DemandForm", - sa.Column("id", sa.String(), nullable=False), - sa.Column("destinationLabel", sa.String(), nullable=False, server_default="目的地/玩法"), - sa.Column("destinationPlaceholder", sa.String(), nullable=True), - sa.Column("phoneLabel", sa.String(), nullable=False, server_default="联系方式"), - sa.Column("phonePlaceholder", sa.String(), nullable=True), - sa.Column("noteLabel", sa.String(), nullable=False, server_default="补充说明"), - sa.Column("notePlaceholder", sa.String(), nullable=True), - sa.Column("submitLabel", sa.String(), nullable=False, server_default="提交出行需求"), - sa.Column("chips", postgresql.ARRAY(sa.String()), nullable=False, server_default=sa.text("'{}'")), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - if "DemandRecommendation" not in existing_tables: - op.create_table( - "DemandRecommendation", - sa.Column("id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("subtitle", sa.Text(), nullable=True), - sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - - -def downgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - for table_name in [ - "DemandRecommendation", - "DemandForm", - "DemandFeatureCard", - "DemandHero", - ]: - if table_name in existing_tables: - op.drop_table(table_name) diff --git a/WonderQ-Admin/alembic/versions/0009_remove_product_domain.py b/WonderQ-Admin/alembic/versions/0009_remove_product_domain.py deleted file mode 100644 index fad4cd9..0000000 --- a/WonderQ-Admin/alembic/versions/0009_remove_product_domain.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Remove the retired product domain and its foreign-key references. - -Revision ID: 0009_remove_product_domain -Revises: 0008_demand_page_modules -Create Date: 2026-08-17 -""" - -from alembic import op -from sqlalchemy import inspect - - -revision = "0009_remove_product_domain" -down_revision = "0008_demand_page_modules" -branch_labels = None -depends_on = None - - -PRODUCT_TABLES = ( - "CampaignProduct", - "RouteSectionProduct", - "DemandRecommendationProduct", - "ProductImage", - "Product", -) - - -def _drop_column_with_foreign_keys(table_name: str, column_name: str) -> None: - bind = op.get_bind() - inspector = inspect(bind) - if table_name not in inspector.get_table_names(): - return - columns = {column["name"] for column in inspector.get_columns(table_name)} - if column_name not in columns: - return - for foreign_key in inspector.get_foreign_keys(table_name): - if column_name in foreign_key.get("constrained_columns", []): - constraint_name = foreign_key.get("name") - if constraint_name: - op.drop_constraint(constraint_name, table_name, type_="foreignkey") - op.drop_column(table_name, column_name) - - -def _drop_referencing_foreign_keys(table_name: str) -> None: - bind = op.get_bind() - inspector = inspect(bind) - for current_table in inspector.get_table_names(): - if current_table == table_name: - continue - for foreign_key in inspector.get_foreign_keys(current_table): - if foreign_key.get("referred_table") != table_name: - continue - constraint_name = foreign_key.get("name") - if constraint_name: - op.drop_constraint(constraint_name, current_table, type_="foreignkey") - - -def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - - _drop_column_with_foreign_keys("Lead", "sourceProductId") - _drop_column_with_foreign_keys("Order", "productId") - - for table_name in (*PRODUCT_TABLES[:3], "RouteSection", "DemandRecommendation", *PRODUCT_TABLES[3:]): - if table_name in existing_tables: - _drop_referencing_foreign_keys(table_name) - op.drop_table(table_name) - - -def downgrade() -> None: - # The retired product schema intentionally has no downgrade path. Restore it from a backup if needed. - pass diff --git a/WonderQ-Admin/alembic/versions/0010_remove_destination_module.py b/WonderQ-Admin/alembic/versions/0010_remove_destination_module.py deleted file mode 100644 index 3df5506..0000000 --- a/WonderQ-Admin/alembic/versions/0010_remove_destination_module.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Remove the retired province destination module. - -Revision ID: 0010_remove_destination_module -Revises: 0009_remove_product_domain -Create Date: 2026-08-17 -""" - -from alembic import op -from sqlalchemy import inspect - - -revision = "0010_remove_destination_module" -down_revision = "0009_remove_product_domain" -branch_labels = None -depends_on = None - - -def _drop_referencing_foreign_keys(table_name: str) -> None: - bind = op.get_bind() - inspector = inspect(bind) - for current_table in inspector.get_table_names(): - if current_table == table_name: - continue - for foreign_key in inspector.get_foreign_keys(current_table): - if foreign_key.get("referred_table") != table_name: - continue - constraint_name = foreign_key.get("name") - if constraint_name: - op.drop_constraint(constraint_name, current_table, type_="foreignkey") - - -def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) - - for table_name in ("DestinationAlias", "Destination"): - if table_name not in existing_tables: - continue - _drop_referencing_foreign_keys(table_name) - op.drop_table(table_name) - - -def downgrade() -> None: - # The retired destination module has no downgrade path. Restore it from a backup if needed. - pass diff --git a/WonderQ-Admin/alembic/versions/0011_remove_cta_banner_module.py b/WonderQ-Admin/alembic/versions/0011_remove_cta_banner_module.py deleted file mode 100644 index ee3f248..0000000 --- a/WonderQ-Admin/alembic/versions/0011_remove_cta_banner_module.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Remove the retired more-services CTA module. - -Revision ID: 0011_remove_cta_banner_module -Revises: 0010_remove_destination_module -Create Date: 2026-08-17 -""" - -from alembic import op -from sqlalchemy import inspect - - -revision = "0011_remove_cta_banner_module" -down_revision = "0010_remove_destination_module" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - if "CtaBanner" in set(inspect(bind).get_table_names()): - op.drop_table("CtaBanner") - - -def downgrade() -> None: - # The retired CTA module has no downgrade path. Restore it from a backup if needed. - pass diff --git a/WonderQ-Admin/alembic/versions/0012_remove_offer_modules.py b/WonderQ-Admin/alembic/versions/0012_remove_offer_modules.py deleted file mode 100644 index 6528cde..0000000 --- a/WonderQ-Admin/alembic/versions/0012_remove_offer_modules.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Remove the retired campaign and hotel site modules. - -Revision ID: 0012_remove_offer_modules -Revises: 0011_remove_cta_banner_module -Create Date: 2026-08-17 -""" - -from alembic import op -from sqlalchemy import inspect - - -revision = "0012_remove_offer_modules" -down_revision = "0011_remove_cta_banner_module" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - existing_tables = set(inspect(op.get_bind()).get_table_names()) - for table_name in ("Campaign", "HotelGroup"): - if table_name in existing_tables: - op.drop_table(table_name) - - -def downgrade() -> None: - # The retired offer modules have no downgrade path. Restore them from a backup if needed. - pass diff --git a/WonderQ-Admin/alembic/versions/0013_remove_map_theme_modules.py b/WonderQ-Admin/alembic/versions/0013_remove_map_theme_modules.py deleted file mode 100644 index c1c47f0..0000000 --- a/WonderQ-Admin/alembic/versions/0013_remove_map_theme_modules.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Remove the retired map and theme site modules. - -Revision ID: 0013_remove_map_theme_modules -Revises: 0012_remove_offer_modules -Create Date: 2026-08-17 -""" - -from alembic import op -from sqlalchemy import inspect - - -revision = "0013_remove_map_theme_modules" -down_revision = "0012_remove_offer_modules" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - existing_tables = set(inspect(op.get_bind()).get_table_names()) - for table_name in ("MapImage", "ThemeCard"): - if table_name in existing_tables: - op.drop_table(table_name) - - -def downgrade() -> None: - # The retired site modules have no downgrade path. Restore them from a backup if needed. - pass diff --git a/WonderQ-Admin/alembic/versions/0014_remove_destination_regions_module.py b/WonderQ-Admin/alembic/versions/0014_remove_destination_regions_module.py deleted file mode 100644 index 5d468ea..0000000 --- a/WonderQ-Admin/alembic/versions/0014_remove_destination_regions_module.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Remove the destination regions module. - -Revision ID: 0014_remove_destination_regions_module -Revises: 0013_remove_map_theme_modules -""" - -from alembic import op -from sqlalchemy import inspect -import sqlalchemy as sa - - -revision = "0014_remove_destination_regions_module" -down_revision = "0013_remove_map_theme_modules" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.alter_column( - "alembic_version", - "version_num", - existing_type=sa.String(length=32), - type_=sa.String(length=128), - existing_nullable=False, - ) - existing_tables = set(inspect(op.get_bind()).get_table_names()) - if "DestinationRegion" in existing_tables: - op.drop_table("DestinationRegion") - - -def downgrade() -> None: - pass diff --git a/WonderQ-Admin/alembic/versions/0015_wanfa.py b/WonderQ-Admin/alembic/versions/0015_wanfa.py index f53c0ef..8e66463 100644 --- a/WonderQ-Admin/alembic/versions/0015_wanfa.py +++ b/WonderQ-Admin/alembic/versions/0015_wanfa.py @@ -1,7 +1,7 @@ """Create the wanfa category and route domain. Revision ID: 0015_wanfa -Revises: 0014_remove_destination_regions_module +Revises: 0001_initial_schema """ from datetime import datetime @@ -12,7 +12,7 @@ from sqlalchemy import inspect revision = "0015_wanfa" -down_revision = "0014_remove_destination_regions_module" +down_revision = "0001_initial_schema" branch_labels = None depends_on = None diff --git a/WonderQ-Admin/app/routers/system.py b/WonderQ-Admin/app/routers/system.py index 81e53f9..db22545 100644 --- a/WonderQ-Admin/app/routers/system.py +++ b/WonderQ-Admin/app/routers/system.py @@ -61,47 +61,21 @@ def _audit(db: Session, request: Request, action: str, entity: str, entity_id: s def _role_dict(role: AdminRole, menu_ids: list[str] | None = None, dept_ids: list[str] | None = None) -> dict: return { - "id": role.id, - "name": role.name, - "code": role.code, - "dataScope": role.dataScope, - "description": role.description, - "isActive": role.isActive, + **model_dict(role), "menuIds": menu_ids or [], "deptIds": dept_ids or [], - "createdAt": role.createdAt, - "updatedAt": role.updatedAt, } def _menu_dict(menu: AdminMenu) -> dict: - return { - "id": menu.id, - "parentId": menu.parentId, - "name": menu.name, - "type": menu.type, - "path": menu.path, - "component": menu.componentKey, - "permission": menu.permissionCode, - "icon": menu.icon, - "sortOrder": menu.sortOrder, - "isVisible": menu.isVisible, - "isActive": menu.isActive, - "createdAt": menu.createdAt, - "updatedAt": menu.updatedAt, - } + data = model_dict(menu) + data["component"] = data.pop("componentKey") + data["permission"] = data.pop("permissionCode") + return data def _dept_dict(dept: AdminDepartment) -> dict: - return { - "id": dept.id, - "parentId": dept.parentId, - "name": dept.name, - "sortOrder": dept.sortOrder, - "isActive": dept.isActive, - "createdAt": dept.createdAt, - "updatedAt": dept.updatedAt, - } + return model_dict(dept) def _ensure_ids_exist(db: Session, model, ids: list[str], label: str) -> None: diff --git a/WonderQ-Admin/app/seed.py b/WonderQ-Admin/app/seed.py index 67d357f..77b3b75 100644 --- a/WonderQ-Admin/app/seed.py +++ b/WonderQ-Admin/app/seed.py @@ -24,6 +24,8 @@ def create_media(db: Session, url: str | None, group: str, name: str | None = No if not url: return media = db.scalar(select(MediaAsset).where(MediaAsset.url == url)) + if media is None: + media = next((item for item in db.new if isinstance(item, MediaAsset) and item.url == url), None) if media: media.group = group media.name = name diff --git a/WonderQ-Admin/tests/test_remove_product_domain_migration.py b/WonderQ-Admin/tests/test_remove_product_domain_migration.py deleted file mode 100644 index 9213ab1..0000000 --- a/WonderQ-Admin/tests/test_remove_product_domain_migration.py +++ /dev/null @@ -1,67 +0,0 @@ -from importlib.util import module_from_spec, spec_from_file_location -from pathlib import Path - - -MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0009_remove_product_domain.py" -SPEC = spec_from_file_location("remove_product_domain_migration", MIGRATION_PATH) -assert SPEC and SPEC.loader -MIGRATION = module_from_spec(SPEC) -SPEC.loader.exec_module(MIGRATION) - - -class LegacySchemaInspector: - def get_table_names(self) -> list[str]: - return ["Product", "MiniProgramFavorite", "MiniProgramHistory"] - - def get_foreign_keys(self, table_name: str) -> list[dict[str, object]]: - foreign_keys = { - "MiniProgramFavorite": [ - { - "name": "MiniProgramFavorite_productId_fkey", - "constrained_columns": ["productId"], - "referred_table": "Product", - "referred_columns": ["id"], - } - ], - "MiniProgramHistory": [ - { - "name": "MiniProgramHistory_productId_fkey", - "constrained_columns": ["productId"], - "referred_table": "Product", - "referred_columns": ["id"], - } - ], - } - return foreign_keys.get(table_name, []) - - -def test_upgrade_drops_legacy_product_foreign_keys_before_product_table(monkeypatch): - events: list[tuple[str, ...]] = [] - inspector = LegacySchemaInspector() - - monkeypatch.setattr(MIGRATION, "inspect", lambda _bind: inspector) - monkeypatch.setattr(MIGRATION.op, "get_bind", lambda: object()) - monkeypatch.setattr( - MIGRATION.op, - "drop_constraint", - lambda name, table_name, type_: events.append(("drop_constraint", name, table_name, type_)), - ) - monkeypatch.setattr(MIGRATION.op, "drop_table", lambda table_name: events.append(("drop_table", table_name))) - - MIGRATION.upgrade() - - assert events == [ - ( - "drop_constraint", - "MiniProgramFavorite_productId_fkey", - "MiniProgramFavorite", - "foreignkey", - ), - ( - "drop_constraint", - "MiniProgramHistory_productId_fkey", - "MiniProgramHistory", - "foreignkey", - ), - ("drop_table", "Product"), - ] diff --git a/WonderQ-Admin/tests/test_seed.py b/WonderQ-Admin/tests/test_seed.py new file mode 100644 index 0000000..0749bde --- /dev/null +++ b/WonderQ-Admin/tests/test_seed.py @@ -0,0 +1,23 @@ +from app.seed import create_media + + +class PendingMediaSession: + def __init__(self): + self.new = [] + + def scalar(self, _statement): + return None + + def add(self, item): + self.new.append(item) + + +def test_create_media_reuses_pending_asset_with_same_url(): + db = PendingMediaSession() + + create_media(db, "/assets/guizhou/shared.jpg", "hero", "首页图片") + create_media(db, "/assets/guizhou/shared.jpg", "vehicle-option", "车型图片") + + assert len(db.new) == 1 + assert db.new[0].group == "vehicle-option" + assert db.new[0].name == "车型图片" diff --git a/WonderQ-Admin/tests/test_system.py b/WonderQ-Admin/tests/test_system.py new file mode 100644 index 0000000..68c3466 --- /dev/null +++ b/WonderQ-Admin/tests/test_system.py @@ -0,0 +1,44 @@ +import json +from datetime import datetime + +from app.models import AdminDepartment, AdminMenu, AdminRole +from app.routers.system import _dept_dict, _menu_dict, _role_dict + + +def test_role_audit_snapshot_serializes_datetime_fields(): + role = AdminRole( + id="role-1", + name="验收角色", + code="acceptance_role", + dataScope="self", + description=None, + isActive=True, + createdAt=datetime(2026, 8, 26, 1, 0), + updatedAt=datetime(2026, 8, 26, 1, 0), + ) + + json.dumps(_role_dict(role)) + + +def test_menu_and_department_audit_snapshots_serialize_datetime_fields(): + menu = AdminMenu( + id="menu-1", + name="验收菜单", + type="page", + sortOrder=1, + isVisible=True, + isActive=True, + createdAt=datetime(2026, 8, 26, 1, 0), + updatedAt=datetime(2026, 8, 26, 1, 0), + ) + dept = AdminDepartment( + id="dept-1", + name="验收部门", + sortOrder=1, + isActive=True, + createdAt=datetime(2026, 8, 26, 1, 0), + updatedAt=datetime(2026, 8, 26, 1, 0), + ) + + json.dumps(_menu_dict(menu)) + json.dumps(_dept_dict(dept)) diff --git a/docs/admin-business-function-matrix.md b/docs/admin-business-function-matrix.md index f1f4421..6351dba 100644 --- a/docs/admin-business-function-matrix.md +++ b/docs/admin-business-function-matrix.md @@ -17,13 +17,13 @@ | --- | --- | --- | --- | --- | --- | | 登录、刷新、退出 | `/api/admin/auth/login`、`refresh`、`logout`、`/api/admin/me` | 兼容旧 `token/user` 字段,401 自动刷新 | 登录页、内存 Access Token、HttpOnly Refresh Cookie | 无 | 自动化通过,待浏览器人工验收 | | 仪表盘 | `/api/admin/dashboard` | 已有 | `DashboardPage` 已接入 | 无 | 读流程已接入 | -| 首页与站点配置 | `/api/admin/site-config` 及模块 CRUD、排序 | 已有 | `HomePage` 已读取全量配置;编辑/排序待迁移 | `/api/public/home`、`/api/public/site-config` | 读契约保持,编辑待验收 | -| 玩法分类与路线 | `/api/admin/wanfa/categories` 及分类/路线 CRUD | 已有 | `WanfaPage` 已读取分类和路线;写操作待迁移 | `/api/public/wanfa/categories` | 读流程已接入,写流程待迁移 | -| 路线详情 | `/api/admin/details` 及详情 CRUD | React 现有玩法流程依赖 | Vue API 类型和页面待补齐 | `/api/public/details/{key}` | 待迁移 | -| 管家 | `/api/admin/concierge/advisors` 及 CRUD | 已有 | `ConciergePage` 已读取顾问;写操作待迁移 | `/api/public/concierge/advisors`、详情顾问信息 | 读流程已接入,写流程待迁移 | -| 需求线索 | `/api/admin/leads`、状态更新 | 已有筛选和状态流转 | `LeadsPage` 已读取;状态更新/筛选待迁移 | `/api/public/leads` | 读流程已接入,写流程待迁移 | -| 媒体 | `/api/admin/media-assets`、上传 | React 已有上传 | Vue 页面和上传流程待迁移 | 通过站点配置间接依赖 | 待迁移 | -| 发布、重置 | `/api/admin/publish`、`reset-guizhou-content` | React 现有入口/能力 | Vue 按权限增加二次确认后迁移 | 发布结果影响 Public API | 待迁移,执行前需要人工确认 | +| 首页与站点配置 | `/api/admin/site-config` 及模块 CRUD、排序 | 已有 | `HomePage` 已迁移全部 7 类站点模块的读写、删除和可用模块排序;单例模块禁止重复创建 | `/api/public/home`、`/api/public/site-config` | 契约、表单校验、接口回归和页面入口已通过,真实数据写入待人工确认 | +| 玩法分类与路线 | `/api/admin/wanfa/categories` 及分类/路线 CRUD | 已有 | `WanfaPage` 已迁移分类/路线新增、编辑、删除、排序和非空分类删除保护 | `/api/public/wanfa/categories` | 契约、接口回归和页面入口已通过,真实数据写入待人工确认 | +| 路线详情 | `/api/admin/details` 及详情 CRUD | React 现有玩法流程依赖 | 已集成到 Vue 路线编辑器,支持详情字段、数组文案、管家关联、启用状态和图片上传/排序 | `/api/public/details/{key}` | 契约、接口回归和页面入口已通过,真实数据写入待人工确认 | +| 管家 | `/api/admin/concierge/advisors` 及 CRUD | 已有 | `ConciergePage` 已接入新增、编辑、启停、排序、删除和头像/二维码上传 | `/api/public/concierge/advisors`、详情顾问信息 | 契约、表单校验和页面入口已通过,真实写入待人工确认 | +| 需求线索 | `/api/admin/leads`、状态更新 | 已有筛选和状态流转 | `LeadsPage` 已接入类型、状态、关键词筛选、详情查看和六态状态写入 | `/api/public/leads` | 契约、有效会话下的列表/筛选/详情页面验收已通过,真实状态写入待人工确认 | +| 媒体 | `/api/admin/media-assets`、上传 | React 已有上传 | `MediaPage` 已接入素材列表、分组、批量上传和图片校验;玩法/管家继续复用上传入口 | 通过站点配置间接依赖 | 契约、列表和上传入口已通过,真实上传待人工确认 | +| 发布、重置 | `/api/admin/publish`、`reset-guizhou-content` | React 现有入口/能力 | `OperationsToolsPage` 已接入发布和重置,均有权限控制、进行中禁用和二次确认 | 发布结果影响 Public API | 入口和确认闸门已通过,执行前必须人工确认 | | RBAC 用户 | `/api/admin/system/users` | 旧角色字段继续兼容 | 系统资源入口已接入读取 | 无 | 后端/读页面已接入,编辑待迁移 | | RBAC 角色、菜单、部门 | `/api/admin/system/roles`、`menus`、`depts` | 旧端不删除 | Vue 动态菜单、组件白名单和系统资源读取已接入 | 无 | 后端/读页面已接入,完整按钮授权待验收 | @@ -37,3 +37,12 @@ ## 删除 React 的闸门 只有当矩阵中 Vue 列全部变为“已接入并人工验收”,并且登录刷新、权限、数据范围、Redis 故障、Public API 回归和 Docker 验证记录齐全后,才可以征求确认切换 `/admin` 与删除 `/admin-legacy`。 + +## 阶段验收记录(2026-08-26) + +- 线索状态:已在 Vue 管理端将真实线索从“待处理”改为“已联系”,再恢复为“待处理”,写入和恢复均成功。 +- 发布与重置:发布成功;重置成功,Public API 和 MiniAPP 已读取重置后的站点内容。 +- RBAC:用户、角色、菜单、部门均完成真实编辑写入并恢复原值,编辑成功提示和最终列表值正常。 +- 媒体上传:按当前验收顺序暂缓,放到最后;此前验证发现 OSS 返回 403,尚未形成素材记录。 +- 自动化回归:Vue 15 个测试文件、25 个测试通过,TypeScript 检查和生产构建通过;后端 134 个测试通过,7 个既有 Alembic 兼容性测试失败,未涉及本轮站点重置和 RBAC 改动。 +- React 删除:未执行。需待媒体上传、登录刷新、权限/数据范围、Redis 异常、Public API 和 Docker 验收全部完成,并经人工确认后再处理。 diff --git a/docs/superpowers/plans/2026-08-26-admin-vue-formalization.md b/docs/superpowers/plans/2026-08-26-admin-vue-formalization.md new file mode 100644 index 0000000..3e75494 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-admin-vue-formalization.md @@ -0,0 +1,468 @@ +# WonderQ Admin Vue 正式化实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不删除 React 过渡端的前提下,将四组首页 CRUD 接入 Vue,补齐管理员踢下线、显式 403、五级数据范围和 `/admin`、`/admin-legacy` 正式代理,并完成可重复的三端验收。 + +**Architecture:** 保持 FastAPI + PostgreSQL + Redis 的现有模块化单体。后端继续以既有 Admin API 和 `{code,msg,data}` 契约为边界;Vue 通过独立的 API client、页面组件和静态路由注册实现新管理端;React 继续由独立 legacy 静态入口提供回滚能力。Redis 只保存会话记录、用户会话索引和权限缓存,不新增会话数据库表。 + +**Tech Stack:** Python 3、FastAPI、SQLAlchemy 2、Alembic、PostgreSQL、Redis、JWT、Vue 3、Element Plus、TypeScript、Pinia、Vue Router 4、React、Vite、Nginx、Docker Compose、pytest、Vitest。 + +--- + +## 文件边界与责任 + +### 后端 + +- 修改 `WonderQ-Admin/app/redis_session.py`:扩展会话存储协议,实现按用户索引、列出和批量撤销会话;不改变 Token 明文存储策略。 +- 修改 `WonderQ-Admin/app/auth.py`:在登录创建和刷新轮换时维护用户会话索引;保留旧登录响应字段。 +- 修改 `WonderQ-Admin/app/routers/system.py`:新增踢下线接口,执行权限、数据范围、当前会话保护和审计。 +- 修改 `WonderQ-Admin/app/routers/admin.py`:为四组首页资源接入现有权限码和数据范围检查,保持既有路径与返回字段。 +- 修改 `WonderQ-Admin/app/rbac.py`:固定数据范围和权限缓存故障的安全行为,确保资源列表与单条写入使用同一范围规则。 +- 修改 `WonderQ-Admin/tests/test_home.py`、`tests/test_admin_rbac.py`:补充首页权限、范围和契约回归。 +- 新建 `WonderQ-Admin/tests/test_admin_sessions.py`:覆盖 Redis 会话索引、刷新轮换、批量撤销和故障行为。 +- 新建 `WonderQ-Admin/tests/test_data_scope_matrix.py`:覆盖五种数据范围的跨部门矩阵。 + +### Vue 管理端 + +- 修改 `WonderQ-Admin-UI-Vue/src/types.ts`:增加四组首页资源类型、403 错误类型、会话撤销结果类型。 +- 修改 `WonderQ-Admin-UI-Vue/src/api/client.ts`:增加首页 CRUD、踢下线请求和显式 403 错误;不改变 API base、Cookie 或 Access Token 键。 +- 新建 `WonderQ-Admin-UI-Vue/src/api/client-home.test.ts`:验证四组首页 API 方法、HTTP 方法和路径。 +- 修改 `WonderQ-Admin-UI-Vue/src/api/client-system.test.ts`:验证踢下线 API。 +- 新建 `WonderQ-Admin-UI-Vue/src/components/home/HomeCrudEditor.vue`:共享首页卡片表单、校验、图片 URL/状态字段和提交态。 +- 新建 `WonderQ-Admin-UI-Vue/src/components/home/HomeCrudTable.vue`:共享列表、空态、错误态、重试、排序和权限按钮。 +- 新建 `WonderQ-Admin-UI-Vue/src/lib/home-content.ts`:定义四组资源的字段元数据、默认值和 payload 归一化。 +- 新建 `WonderQ-Admin-UI-Vue/src/pages/HomeContentPage.vue`:体验、团建、野奢三组 CRUD 页面。 +- 新建 `WonderQ-Admin-UI-Vue/src/pages/HomeWanfaRecommendationPage.vue`:玩法推荐关联 CRUD 页面。 +- 新建 `WonderQ-Admin-UI-Vue/src/pages/ForbiddenPage.vue`:登录态下的 403 页面。 +- 修改 `WonderQ-Admin-UI-Vue/src/pages/HomePage.vue`、`src/router/index.ts`:把首页站点配置与四组业务入口拆开,并注册 403 和首页业务路由。 +- 修改 `WonderQ-Admin-UI-Vue/src/pages/SystemResourcePage.vue`:在用户列表增加踢下线操作。 +- 新建 `WonderQ-Admin-UI-Vue/src/router/router.test.ts`:验证 401、403、无权限页面和未知路径的分流。 + +### 部署与契约 + +- 修改 `docs/home-api.md`:补充 Vue 对接字段、统一错误和四组后台验收矩阵。 +- 修改 `docs/admin-api-requirements.md`:补充踢下线、403、数据范围和 Redis 故障契约。 +- 修改 `docs/integration-workflow.md`:补充双管理端、正式代理和回滚命令。 +- 修改 `docs/admin-business-function-matrix.md`:记录每个切片的实际测试和人工验收结果。 +- 新建 `WonderQ-Admin-UI-Vue/Dockerfile`、`WonderQ-Admin-UI-Vue/nginx.conf`:构建并提供 `/admin/` 静态资源。 +- 修改 `WonderQ-Admin-UI/vite.config.ts`、`WonderQ-Admin-UI/nginx.conf`、`WonderQ-Admin-UI/Dockerfile`:支持 `/admin-legacy/` base 和 legacy 静态入口。 +- 新建 `deploy/nginx/wonderq-admin.conf`:统一代理 `/admin/`、`/admin-legacy/` 和 `/api/`;不写入任何真实环境变量。 +- 新建 `scripts/verify-admin-proxy.ps1`:检查两个构建产物和统一 Nginx 配置的入口、深链与 API 隔离。 +- 修改 `docker-compose.yml`(仅在现有 Compose 确认包含前端服务时):加入两个前端构建入口和健康检查;若现有 Compose 不负责前端,则只增加部署文档,不扩大 Compose 责任。 + +## Task 1: 建立契约基线与失败测试入口 + +**Files:** +- Modify: `docs/home-api.md` +- Modify: `docs/admin-api-requirements.md` +- Modify: `docs/integration-workflow.md` +- Test: `WonderQ-Admin/tests/test_home.py` +- Test: `WonderQ-Admin-UI-Vue/src/api/client-home.test.ts` + +- [ ] **Step 1: 记录四组首页的现有字段与接口响应** + +从 `WonderQ-Admin/app/schemas.py`、`WonderQ-Admin/app/routers/admin.py`、`WonderQ-Admin-UI/src/api.ts` 和 `docs/home-api.md` 对照四组资源。契约表必须明确 `GET/POST/PATCH/DELETE/PATCH reorder`、成功状态码、空数组、排序完整 ID 列表和 403 响应,不改现有公共字段名。 + +- [ ] **Step 2: 先写 Vue API 失败测试** + +在 `client-home.test.ts` 中为四组资源各调用一次列表、创建、更新、删除、排序方法,断言 `fetch` 收到以下请求: + +```ts +expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + ["/api/admin/home/experiences", undefined], + ["/api/admin/home/experiences", "POST"], + ["/api/admin/home/experiences/experience-1", "PATCH"], + ["/api/admin/home/experiences/experience-1", "DELETE"], + ["/api/admin/home/experiences/reorder", "PATCH"], + ["/api/admin/home/team-buildings", undefined], + ["/api/admin/home/team-buildings", "POST"], + ["/api/admin/home/team-buildings/team-1", "PATCH"], + ["/api/admin/home/team-buildings/team-1", "DELETE"], + ["/api/admin/home/team-buildings/reorder", "PATCH"], + ["/api/admin/home/wild-archives", undefined], + ["/api/admin/home/wild-archives", "POST"], + ["/api/admin/home/wild-archives/archive-1", "PATCH"], + ["/api/admin/home/wild-archives/archive-1", "DELETE"], + ["/api/admin/home/wild-archives/reorder", "PATCH"], + ["/api/admin/home/play-recommendations", undefined], + ["/api/admin/home/play-recommendations", "POST"], + ["/api/admin/home/play-recommendations/recommendation-1", "PATCH"], + ["/api/admin/home/play-recommendations/recommendation-1", "DELETE"], + ["/api/admin/home/play-recommendations/reorder", "PATCH"], +]); +``` + +- [ ] **Step 3: 运行失败测试,确认缺口真实存在** + +Run: `Set-Location WonderQ-Admin-UI-Vue; yarn test --run src/api/client-home.test.ts` + +Expected: FAIL because the four Vue client methods and corresponding types do not exist yet. + +- [ ] **Step 4: 同步契约文档并检查差异** + +Run: `git diff --check -- docs/home-api.md docs/admin-api-requirements.md docs/integration-workflow.md` + +Expected: no whitespace errors;文档中没有真实 Token、密码、OSS 凭证或生产地址。 + +## Task 2: 接入四组首页 API 与后端权限/范围 + +**Files:** +- Modify: `WonderQ-Admin-UI-Vue/src/types.ts` +- Modify: `WonderQ-Admin-UI-Vue/src/api/client.ts` +- Modify: `WonderQ-Admin/app/routers/admin.py` +- Modify: `WonderQ-Admin/tests/test_home.py` +- Modify: `WonderQ-Admin-UI-Vue/src/api/client-home.test.ts` + +- [ ] **Step 1: 写后端首页鉴权和范围失败测试** + +为四组列表和至少一个更新接口增加测试:普通管理员没有 `admin:site-config` 时返回 403;有该权限时只能读取或更新 `is_within_data_scope` 为真的记录;完整排序请求缺 ID 或重复 ID 时返回 400。测试继续使用现有依赖覆盖方式,不创建真实生产数据。 + +- [ ] **Step 2: 运行后端失败测试** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_home.py -q` + +Expected: new permission/scope assertions fail while existing home CRUD compatibility tests remain visible in the output。 + +- [ ] **Step 3: 增加 TypeScript 资源类型和 API 方法** + +在 `src/types.ts` 中以 React 已有类型和后端 schema 为准定义 `HomeExperience`、`HomeTeamBuilding`、`HomeWildArchive`、`HomeWanfaRecommendation` 及对应 Create/Patch 类型;在 `src/api/client.ts` 中提供以下函数: + +```ts +getHomeExperiences(); createHomeExperience(input); updateHomeExperience(id, input); +deleteHomeExperience(id); reorderHomeExperiences(itemIds); +getHomeTeamBuildings(); createHomeTeamBuilding(input); updateHomeTeamBuilding(id, input); +deleteHomeTeamBuilding(id); reorderHomeTeamBuildings(itemIds); +getHomeWildArchives(); createHomeWildArchive(input); updateHomeWildArchive(id, input); +deleteHomeWildArchive(id); reorderHomeWildArchives(itemIds); +getHomePlayRecommendations(); createHomePlayRecommendation(input); +updateHomePlayRecommendation(id, input); deleteHomePlayRecommendation(id); +reorderHomePlayRecommendations(itemIds); +``` + +每个方法只调用既有 `/api/admin/home/...` 路径,并由 `request` 统一解包响应。 + +- [ ] **Step 4: 将后端四组路由切换到已有权限依赖并复用数据范围** + +把当前四组首页路由的 `Depends(require_admin)` 替换为 `Depends(require_admin_permission("admin:site-config"))`,列表通过 `apply_data_scope`,单条更新、删除和排序前通过 `is_within_data_scope`;保留超级管理员兼容行为、既有审计事件和返回结构。写入记录继续使用现有创建人/部门回填逻辑。 + +- [ ] **Step 5: 运行后端与 Vue API 测试** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_home.py -q` + +Run: `Set-Location ..\WonderQ-Admin-UI-Vue; yarn test --run src/api/client-home.test.ts` + +Expected: new assertions PASS;如全量后端仍出现已知 Alembic 兼容失败,记录其具体测试名,不将其归因于首页切片。 + +## Task 3: 迁移首页三组内容 CRUD 页面 + +**Files:** +- Create: `WonderQ-Admin-UI-Vue/src/lib/home-content.ts` +- Create: `WonderQ-Admin-UI-Vue/src/components/home/HomeCrudEditor.vue` +- Create: `WonderQ-Admin-UI-Vue/src/components/home/HomeCrudTable.vue` +- Create: `WonderQ-Admin-UI-Vue/src/pages/HomeContentPage.vue` +- Modify: `WonderQ-Admin-UI-Vue/src/router/index.ts` +- Modify: `WonderQ-Admin-UI-Vue/src/pages/HomePage.vue` +- Test: `WonderQ-Admin-UI-Vue/src/lib/home-content.test.ts` + +- [ ] **Step 1: 写字段元数据和表单归一化失败测试** + +测试必须分别检查三组资源的必填字段、空字符串裁剪、数字字段转换、`sortOrder` 默认值、`isActive` 默认值和错误文案。测试输入只使用测试文本和本地图片 URL。 + +- [ ] **Step 2: 运行失败测试** + +Run: `Set-Location WonderQ-Admin-UI-Vue; yarn test --run src/lib/home-content.test.ts` + +Expected: FAIL because the metadata module does not exist。 + +- [ ] **Step 3: 实现元数据模块** + +`home-content.ts` 导出资源联合类型、资源定义、`createEmptyHomeDraft`、`toHomePayload` 和 `validateHomePayload`。所有字段名必须与 `docs/home-api.md` 和后端 Pydantic schema 一致;不把 base64 文件内容写入 CRUD payload,只保存媒体接口返回的 URL。 + +- [ ] **Step 4: 实现共享表格与编辑器** + +`HomeCrudTable.vue` 接收 `items/loading/error/permission`,提供空态、错误重试、编辑、删除、上下移和提交中禁用;`HomeCrudEditor.vue` 接收资源定义和 draft,提供 Element Plus 表单校验、取消、提交中状态和 `save` 事件。按钮使用 `v-permission="'admin:site-config'"`,后端 403 仍由页面捕获显示错误。 + +- [ ] **Step 5: 实现三组 Vue 页面** + +`HomeContentPage.vue` 根据路由参数选择 `experiences`、`team-buildings` 或 `wild-archives`,加载、创建、更新、删除和排序均调用 Task 2 的 API 方法。列表、编辑器和请求状态按资源隔离,避免修改一组数据时污染另外两组。页面提供加载骨架、空态、错误重试和窄屏表格滚动。 + +- [ ] **Step 6: 将首页入口与新页面注册到静态路由** + +保留 `/admin/home` 作为站点配置页,在该页增加三个明确入口;在 `router/index.ts` 增加 `/home/content/:resource` 静态路由并绑定 `HomeContentPage`,权限使用 `admin:site-config`。组件仍必须来自本地预注册表,不接受后端任意组件路径。 + +- [ ] **Step 7: 运行 Vue 单元、类型和构建验证** + +Run: `Set-Location WonderQ-Admin-UI-Vue; yarn test --run src/lib/home-content.test.ts src/api/client-home.test.ts` + +Run: `yarn build` + +Expected: tests and TypeScript build PASS;Vite 已有 chunk 大小或 `__dirname` warning 可记录,但不得出现编译错误。 + +## Task 4: 迁移玩法推荐关联 CRUD + +**Files:** +- Create: `WonderQ-Admin-UI-Vue/src/pages/HomeWanfaRecommendationPage.vue` +- Modify: `WonderQ-Admin-UI-Vue/src/router/index.ts` +- Modify: `WonderQ-Admin-UI-Vue/src/pages/HomePage.vue` +- Test: `WonderQ-Admin-UI-Vue/src/api/client-home.test.ts` +- Test: `WonderQ-Admin/tests/test_home.py` + +- [ ] **Step 1: 写玩法推荐列表、关联和排序失败测试** + +后端测试验证关联不存在的玩法分类返回 404/400、删除关联不删除分类、状态更新只改变关联、排序必须包含当前全部关联 ID。Vue API 测试验证 `play-recommendations` 的五个方法路径。 + +- [ ] **Step 2: 运行失败测试** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_home.py -q` + +Run: `Set-Location ..\WonderQ-Admin-UI-Vue; yarn test --run src/api/client-home.test.ts` + +Expected: new Vue methods and missing edge-case assertions fail。 + +- [ ] **Step 3: 实现 Vue 玩法推荐页面** + +页面加载现有玩法分类作为选择项,单独显示已关联推荐;创建和编辑只提交后端允许的分类 ID、状态和排序字段;删除操作明确提示“只移除首页关联,不删除玩法分类”;复用 `HomeCrudTable` 的空态、错误态、排序和权限按钮。 + +- [ ] **Step 4: 运行玩法推荐切片验收** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_home.py -q` + +Run: `Set-Location ..\WonderQ-Admin-UI-Vue; yarn test --run; yarn build` + +Expected: 后端首页测试、Vue 现有 25 项基线测试和构建全部通过;失败项记录到业务矩阵后再进入下一阶段。 + +## Task 5: 实现管理员踢下线 + +**Files:** +- Modify: `WonderQ-Admin/app/redis_session.py` +- Modify: `WonderQ-Admin/app/auth.py` +- Modify: `WonderQ-Admin/app/routers/system.py` +- Modify: `WonderQ-Admin/tests/test_admin_rbac.py` +- Create: `WonderQ-Admin/tests/test_admin_sessions.py` +- Modify: `WonderQ-Admin-UI-Vue/src/types.ts` +- Modify: `WonderQ-Admin-UI-Vue/src/api/client.ts` +- Modify: `WonderQ-Admin-UI-Vue/src/api/client-system.test.ts` +- Modify: `WonderQ-Admin-UI-Vue/src/pages/SystemResourcePage.vue` + +- [ ] **Step 1: 写会话索引失败测试** + +在 `test_admin_sessions.py` 验证: + +```python +store = InMemoryAdminSessionStore() +store.create(session_id="s-1", user_id="u-1", access_jti="a-1", refresh_hash="r-1", access_expires_at=future, refresh_expires_at=future) +store.create(session_id="s-2", user_id="u-1", access_jti="a-2", refresh_hash="r-2", access_expires_at=future, refresh_expires_at=future) +assert store.revoke_user_sessions("u-1", except_session_id="s-1") == 1 +assert store.is_access_active("s-1", "a-1") +assert not store.is_access_active("s-2", "a-2") +``` + +同时验证 rotate 保留同一会话索引、revoke 删除索引、重复批量撤销返回 0、Redis 异常抛出 `RedisUnavailableError`。 + +- [ ] **Step 2: 运行会话失败测试** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_admin_sessions.py -q` + +Expected: FAIL because the protocol and stores do not expose `revoke_user_sessions`。 + +- [ ] **Step 3: 扩展协议和两种存储实现** + +在 `AdminSessionStore` 增加 `list_user_sessions(user_id)` 和 `revoke_user_sessions(user_id, except_session_id=None)`。InMemory 使用 `dict[user_id, set[session_id]]` 或从现有记录派生并在 create/rotate/revoke 中保持一致;Redis 使用 `wonderq:admin:user-sessions:{user_id}` Set,成员仅为 session ID,批量删除 session key、refresh key 和 Set 成员。所有 Redis 客户端异常统一为 `RedisUnavailableError`。 + +- [ ] **Step 4: 运行会话存储测试并修复轮换回归** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_admin_sessions.py tests/test_admin_rbac.py -q` + +Expected: session index, JWT rotation, logout and existing legacy login fields all PASS。 + +- [ ] **Step 5: 写踢下线路由失败测试** + +使用 FastAPI `TestClient` 和依赖覆盖验证:没有 `system:user:update` 返回 403;目标用户不存在返回 404;目标为当前用户返回 400;成功时排除当前 session、返回撤销数量并写入审计记录;Redis 不可用返回服务错误而不是继续放行。 + +- [ ] **Step 6: 实现 system 路由与前端 API** + +新增 `POST /api/admin/system/users/{user_id}/sessions/revoke`,依赖 `require_permission("system:user:update")`,使用当前 JWT 的 `sid` 作为排除项,调用 `revoke_user_sessions`,审计内容只记录操作者、目标用户和数量。Vue 增加 `revokeSystemUserSessions(userId)`,系统用户行增加“踢下线”确认按钮、提交中禁用和结果提示。 + +- [ ] **Step 7: 运行踢下线全链路测试** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_admin_sessions.py tests/test_admin_rbac.py -q` + +Run: `Set-Location ..\WonderQ-Admin-UI-Vue; yarn test --run src/api/client-system.test.ts` + +Expected: backend and API client tests PASS;浏览器验收时用两个登录会话确认被踢会话的下一次请求收到 401 并回到登录页。 + +## Task 6: 增加显式 403 页面与错误分流 + +**Files:** +- Modify: `WonderQ-Admin-UI-Vue/src/api/client.ts` +- Create: `WonderQ-Admin-UI-Vue/src/pages/ForbiddenPage.vue` +- Modify: `WonderQ-Admin-UI-Vue/src/router/index.ts` +- Create: `WonderQ-Admin-UI-Vue/src/router/router.test.ts` + +- [ ] **Step 1: 写 403 与 401 分流失败测试** + +测试 `request` 收到 403 时抛出 `PermissionDeniedError`,不调用 `/auth/refresh`;收到 401 时仍只刷新一次;路由守卫对已登录无权限页面返回 `forbidden`,未登录返回 `login`,未知路径返回 `not-found`。 + +- [ ] **Step 2: 运行失败测试** + +Run: `Set-Location WonderQ-Admin-UI-Vue; yarn test --run src/router/router.test.ts` + +Expected: FAIL because 403 error class、forbidden route and explicit guard branch do not exist。 + +- [ ] **Step 3: 实现错误类与 403 页面** + +新增 `PermissionDeniedError extends ApiRequestError`,状态码固定为 403;`request` 保持 403 原样解包并抛出该错误。新增 `/forbidden` 公共渲染页面,显示无权限、返回上一页和回到首页操作,不清除 Pinia 登录态。 + +- [ ] **Step 4: 修改路由守卫** + +把当前 `return { name: "not-found" }` 的权限分支改为 `return { name: "forbidden", query: { from: to.fullPath } }`;保留动态路由只从 `componentRegistry` 注册。系统资源路由仍根据用户、角色、菜单、部门分别检查权限码。 + +- [ ] **Step 5: 运行 403、现有测试和构建** + +Run: `Set-Location WonderQ-Admin-UI-Vue; yarn test --run; yarn build` + +Expected: 403 不触发刷新、不误导为 404,现有测试和构建 PASS。 + +## Task 7: 完成五级数据范围自动化矩阵 + +**Files:** +- Modify: `WonderQ-Admin/app/rbac.py` +- Modify: `WonderQ-Admin/app/routers/admin.py` +- Modify: `WonderQ-Admin/tests/test_data_scope_matrix.py` +- Modify: `WonderQ-Admin/tests/test_admin_rbac.py` +- Modify: `docs/admin-api-requirements.md` + +- [ ] **Step 1: 建立隔离测试数据** + +测试 fixture 创建部门 `dept-a`、子部门 `dept-a-child`、`dept-b`,管理员 `user-a`、`user-b`,并创建带 `deptId` 和 `createdById` 的线索、玩法详情、管家、媒体和首页资源。fixture 每次测试独立回滚,不使用开发数据库真实业务数据。 + +- [ ] **Step 2: 写五种范围失败测试** + +分别为 `all`、`dept`、`dept_and_children`、`custom_dept`、`self` 配置角色,断言列表只返回预期 ID;同时调用详情、更新、删除或状态写入,确认跨范围资源返回 404/403,不能只在前端隐藏。 + +- [ ] **Step 3: 运行范围矩阵失败测试** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_data_scope_matrix.py -q` + +Expected: 对未覆盖资源或错误的异常状态出现失败,输出具体资源和范围编码。 + +- [ ] **Step 4: 统一范围过滤与写入检查** + +确保 `apply_data_scope` 对目录列表使用部门/创建人 OR 规则;`is_within_data_scope` 用于详情、更新、删除、状态和排序;缺少归属字段的旧记录由既有回填规则处理,不把无归属资源错误暴露给非超级管理员。保留 `admin/super_admin` 的全量兼容行为。 + +- [ ] **Step 5: 固定 Redis 权限缓存故障语义** + +权限缓存读取或写入失败时,允许数据库重新计算权限但不得把异常转换成放行;需要 Redis 会话校验的接口继续拒绝。为 `RedisUnavailableError` 增加测试,确认不能因 Redis 断开返回全量菜单或全量业务数据。 + +- [ ] **Step 6: 运行范围和后端回归** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest tests/test_data_scope_matrix.py tests/test_admin_rbac.py tests/test_home.py -q` + +Expected: five-level matrix and affected business tests PASS;与本切片无关的既有 Alembic bootstrap 失败单独记录,不跳过新测试。 + +## Task 8: 建立 Docker 与正式双入口代理 + +**Files:** +- Create: `WonderQ-Admin-UI-Vue/Dockerfile` +- Create: `WonderQ-Admin-UI-Vue/nginx.conf` +- Modify: `WonderQ-Admin-UI/Dockerfile` +- Modify: `WonderQ-Admin-UI/nginx.conf` +- Modify: `WonderQ-Admin-UI/vite.config.ts` +- Create: `deploy/nginx/wonderq-admin.conf` +- Modify: `docs/integration-workflow.md` + +- [ ] **Step 1: 写代理静态检查** + +新建 `scripts/verify-admin-proxy.ps1`,断言 Vue 构建产物引用 `/admin/`,React legacy 构建产物引用 `/admin-legacy/`,Nginx 配置包含 `/api/` proxy、两个 SPA fallback 和独立 `/healthz`,且不存在把 `/api/` fallback 到前端 index 的规则。 + +- [ ] **Step 2: 运行代理失败检查** + +Run: `rg -n "admin-legacy|/admin/|proxy_pass|try_files|location /api" WonderQ-Admin-UI WonderQ-Admin-UI-Vue deploy/nginx` + +Expected: 当前 React 入口和 Vue Docker 入口缺少至少一项,检查失败并列出缺失路径。 + +- [ ] **Step 3: 配置 Vue 静态镜像** + +Vue Dockerfile 使用 Node 构建 `yarn build`,Nginx 配置把 `/` 的容器根目录作为 Vue `/admin/` 资源根,并对 `/admin/` history fallback 到 `/admin/index.html`。健康检查使用 `/healthz`,不暴露环境变量。 + +- [ ] **Step 4: 配置 React legacy base 和镜像** + +在 React Vite 配置中读取 `VITE_BASE_PATH`,默认开发仍为 `/`,正式 legacy 构建传入 `/admin-legacy/`;React Nginx 资源根、history fallback 和静态资源链接与该 base 一致。原有 API proxy 开发行为保持不变。 + +- [ ] **Step 5: 编写统一部署 Nginx 配置** + +统一配置明确以下映射: + +```nginx +location /api/ { proxy_pass http://wonderq-api:4000; } +location /admin/ { alias /srv/wonderq-vue/; try_files $uri $uri/ /admin/index.html; } +location /admin-legacy/ { alias /srv/wonderq-react/; try_files $uri $uri/ /admin-legacy/index.html; } +``` + +实际文件路径按镜像挂载目录调整,但必须保留三条逻辑边界,且 `/api/` 位于 SPA fallback 之前并单独转发。 + +- [ ] **Step 6: 构建并验证容器** + +Run: `docker compose config` + +Run: `docker build -t wonderq-admin-ui-vue:acceptance .\WonderQ-Admin-UI-Vue` + +Run: `docker build --build-arg VITE_BASE_PATH=/admin-legacy/ -t wonderq-admin-ui-react-legacy:acceptance .\WonderQ-Admin-UI` + +Run: `docker compose up -d postgres redis` + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m alembic upgrade head` + +Expected: 两个前端镜像、PostgreSQL、Redis 构建/启动成功;`http://localhost:4000/health` 和两个静态入口可访问。若当前 Compose 不包含 API 服务,只执行现有 API 启动命令并记录边界。 + +## Task 9: 三端回归、人工验收与 React 删除闸门 + +**Files:** +- Modify: `docs/admin-business-function-matrix.md` +- Modify: `docs/integration-workflow.md` +- Read-only verification: `WonderQ-MiniAPP/src/lib/api.ts`, `WonderQ-Admin-UI/src/api.ts` + +- [ ] **Step 1: 运行后端完整回归** + +Run: `Set-Location WonderQ-Admin; .\.venv\Scripts\python.exe -m pytest -q` + +Expected: 新增切片全部通过;既有 Alembic 兼容失败必须保留完整测试名和原因,不能用“部分通过”掩盖。 + +- [ ] **Step 2: 运行两套管理端回归** + +Run: `Set-Location ..\WonderQ-Admin-UI-Vue; yarn test --run; yarn build` + +Run: `Set-Location ..\WonderQ-Admin-UI; yarn build` + +Expected: Vue 测试/构建和 React 构建通过;旧 React API 路径、登录响应字段和刷新逻辑没有被删改。 + +- [ ] **Step 3: 做 MiniAPP Public API 兼容检查** + +确认 `WonderQ-MiniAPP/src/lib/api.ts` 仍只访问 `/api/public/...`,首页 `experiences`、团队共创和极境视界字段与 `docs/public-api.md` 一致。只有 Public API 或字段发生变化时才运行 MiniAPP 类型检查和测试。 + +- [ ] **Step 4: 人工验收顺序** + +使用独立测试账号逐项完成: + +1. 四组首页新建、编辑、删除、排序、启停和错误重试; +2. 两个登录会话验证踢下线; +3. 无页面权限 403、无按钮权限和直接 API 403; +4. 五种数据范围跨部门列表与写入; +5. Redis 异常、Docker、`/admin/`、`/admin-legacy/` 和深链刷新; +6. Public API/MiniAPP 回归; +7. 最后进行 OSS/媒体上传验收。 + +每项将实际 URL、操作结果和失败证据写入 `docs/admin-business-function-matrix.md`,不记录账号密码、Token 或密钥。 + +- [ ] **Step 5: 判断 React 删除闸门** + +只有 Vue 功能矩阵、Public/Admin API 回归、RBAC 五级范围、会话/Redis、Docker/代理、OSS/媒体和人工验收全部通过,并收到用户明确“删除旧 React”确认,才另开删除计划。当前计划结束时保留 React 源码、依赖、构建入口和 `/admin-legacy/`。 + +## 验证与交付规则 + +- 每个 Task 完成后先运行其局部测试,再运行受影响端的构建;失败时先定位根因,不以扩大改动规避测试。 +- 不执行 `git reset`、`git checkout --`、批量删除或覆盖用户现有改动。 +- 不修改 `.env`、`.env.local`、真实 Docker secrets、OSS 凭证、生产数据库连接或生产部署变量。 +- 不新增 Alembic 迁移;会话索引只使用 Redis。若实现过程中发现确需数据库结构变化,暂停并单独征求确认。 +- 不自动提交或删除 React;阶段结果以 `git diff --check`、测试输出和业务矩阵记录作为检查点。 diff --git a/docs/superpowers/specs/2026-08-26-admin-vue-formalization-design.md b/docs/superpowers/specs/2026-08-26-admin-vue-formalization-design.md new file mode 100644 index 0000000..c538dff --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-admin-vue-formalization-design.md @@ -0,0 +1,220 @@ +# WonderQ Admin Vue 正式化与三端联调设计规格 + +## 1. 文档状态 + +- 日期:2026-08-26 +- 状态:待用户评审 +- 适用范围:WonderQ-Admin、WonderQ-Admin-UI-Vue、WonderQ-Admin-UI、WonderQ-MiniAPP 的本阶段正式化工作 +- 目标:在保留 React 过渡端的前提下,补齐 Vue 管理端未覆盖的首页 CRUD,完成管理员会话踢下线、显式 403、五级数据范围与正式反向代理验收。 + +本阶段不删除 React,不切换生产默认入口,不处理 OSS/媒体上传验收。媒体上传仍作为最后一项验收内容。 + +## 2. 当前基线与缺口 + +现有后端已经提供以下首页管理接口,但 Vue 管理端尚未接入: + +| 功能组 | 接口前缀 | Vue 现状 | React 现状 | +| --- | --- | --- | --- | +| 体验内容 | `/api/admin/home/experiences` | 未接入 | 已有列表、创建、编辑、删除、排序 | +| 团建内容 | `/api/admin/home/team-buildings` | 未接入 | 已有列表、创建、编辑、删除、排序 | +| 野奢档案 | `/api/admin/home/wild-archives` | 未接入 | 已有列表、创建、编辑、删除、排序 | +| 玩法推荐 | `/api/admin/home/play-recommendations` | 未接入 | 已有列表、创建、编辑、删除、排序 | + +现有 Vue 首页只覆盖站点配置模块,不能据此判断旧 React 业务已经全部迁移。 + +当前还存在以下验收缺口: + +1. Redis 会话只支持当前会话退出,没有按用户撤销全部会话的管理能力。 +2. Vue 路由权限失败会落到 Not Found,API 客户端也没有统一的 403 反馈路径。 +3. 五级数据范围已有后端基础模型与角色编辑入口,但缺少覆盖五种范围的完整自动化矩阵和非超级管理员真实验收记录。 +4. React 与 Vue 目前没有统一的生产反向代理入口;React 仍按根路径构建,不能直接挂载到 `/admin-legacy/`。 + +## 3. 目标架构 + +```text +浏览器 + ├─ /admin/ -> Vue3 静态资源,正式管理端 + ├─ /admin-legacy/ -> React 静态资源,过渡管理端 + └─ /api/ -> FastAPI,保持现有 Public API 与 Admin API + +FastAPI + ├─ PostgreSQL:业务数据、RBAC、审计 + └─ Redis:管理员会话、会话索引、权限菜单缓存、限流 +``` + +Vue 继续使用预注册组件加载动态菜单,不允许由后端返回任意前端组件路径。React 保持原有 API 响应格式和登录字段兼容,直到正式删除闸门满足并得到用户确认。 + +## 4. 分阶段实施设计 + +### 阶段一:四组首页 CRUD 垂直切片 + +每个功能组都同时完成 API 客户端、Vue 页面、权限按钮、错误态与测试,不先做一个只读总览页。 + +#### 4.1 API 与数据契约 + +在 Vue API 层增加四组资源的类型和请求函数,沿用后端既有路径、字段、`{code,msg,data}` 包裹和排序语义: + +- `GET /api/admin/home/experiences` +- `POST /api/admin/home/experiences` +- `PATCH /api/admin/home/experiences/{id}` +- `DELETE /api/admin/home/experiences/{id}` +- `POST /api/admin/home/experiences/reorder` +- `GET/POST/PATCH/DELETE/POST /reorder` 对应 `team-buildings` +- `GET/POST/PATCH/DELETE/POST /reorder` 对应 `wild-archives` +- `GET/POST/PATCH/DELETE/POST /reorder` 对应 `play-recommendations` + +实际字段以现有后端 schema 和 `docs/home-api.md` 为准;如果发现契约缺失,先补文档,再同步 TypeScript 类型和后端测试,不擅自改动旧字段名。 + +#### 4.2 Vue 页面拆分 + +不继续把四组业务逻辑堆进 `HomePage.vue`。按资源拆出可维护的页面或业务组件,并复用列表、编辑表单、排序操作、确认弹窗和权限按钮组件。每组至少覆盖: + +- 列表加载、空态、错误态、重试; +- 新建、编辑、删除及提交中禁用态; +- 排序保存与失败回滚提示; +- 图片/链接/状态等现有字段的校验和展示; +- `home:*` 权限码控制按钮显示与接口 403 反馈; +- 桌面端和窄屏布局。 + +验收以 React 页面和后端路由为基线逐项核对,不因 Vue 页面视觉重排而遗漏业务字段或操作。 + +### 阶段二:踢下线 + +#### 4.3 Redis 会话索引 + +在现有会话存储基础上增加按管理员用户索引的 Redis Set,索引只保存随机会话 ID,不保存明文 Refresh Token。创建、刷新轮换、主动退出、过期清理和批量撤销都必须维护索引;Redis 不可用时不得绕过会话校验。 + +会话存储协议增加: + +- 按用户列出会话 ID; +- 按用户撤销全部会话; +- 清理已不存在或已撤销的索引项。 + +不新增数据库表,不把 Refresh Token 写入 PostgreSQL,不在日志中记录 Token 或其完整哈希。 + +#### 4.4 管理接口与 UI + +新增: + +`POST /api/admin/system/users/{user_id}/sessions/revoke` + +要求: + +- 权限码:`system:user:update`; +- 只能由具备该按钮权限且通过数据范围校验的管理员调用; +- 默认撤销目标用户的全部管理员会话; +- 禁止通过该接口撤销当前操作会话,避免误操作造成管理流程中断; +- 目标用户不存在、无数据范围权限或 Redis 不可用时返回明确错误,不返回内部异常; +- 写入审计日志,记录操作者、目标用户、撤销数量和结果,不记录 Token; +- Vue 用户管理页提供按钮、确认提示、提交中状态和成功/失败反馈; +- 被踢出的浏览器在下一次 API 请求或刷新时收到 401,并回到登录页。 + +### 阶段三:显式 403 + +新增 Vue `ForbiddenPage.vue` 和 `/forbidden` 路由。路由守卫在用户已登录但无菜单/页面权限时进入 403 页面;未登录仍进入登录页,未知路径继续进入 Not Found。 + +API 客户端对 403 不刷新 Token、不清除有效登录态,统一转换为可识别的权限错误。页面层显示“无权限”与返回上一页/首页操作;按钮级权限仍由现有权限指令控制,但后端 403 始终视为最终边界。 + +至少验证: + +- 无页面权限访问页面 URL; +- 有页面权限但无按钮权限; +- 直接调用被禁止的 Admin API; +- 401 与 403 不混淆; +- 刷新后权限缓存和动态路由行为一致。 + +### 阶段四:五级数据范围 + +保留现有范围编码和角色编辑能力,补充自动化测试和真实验收,不另造一套权限模型。五种范围必须分别验证: + +1. 全部:可见全部归属资源; +2. 当前部门:只见当前部门资源; +3. 当前部门及子部门:可见当前部门和后代部门资源; +4. 自定义部门:只见角色明确配置的部门资源; +5. 本人:只见创建人/负责人为自己的资源。 + +测试使用至少两个部门、一个子部门、两个管理员和跨部门资源,覆盖列表、详情、更新、删除/状态写入等接口。每个切片都检查数据范围过滤在服务端生效,不能只依赖前端隐藏行。 + +权限或部门变更后必须使权限菜单缓存和相关数据范围缓存失效。Redis 不可用时拒绝需要会话或权限缓存的请求,不降级为全量数据。 + +### 阶段五:Docker 与正式反向代理 + +新增部署级 Nginx 配置和 Vue 静态镜像入口,统一提供: + +- `/admin/`:Vue 正式管理端,使用 Vue 的 `/admin/` base; +- `/admin-legacy/`:React 过渡端,React 构建必须使用 `/admin-legacy/` base; +- `/api/`:反向代理到 FastAPI; +- 两个前端入口都支持 SPA history fallback,但不能把 `/api/` 错误回退到 `index.html`。 + +React 通过构建环境或独立 legacy 构建配置注入 base,不改变现有开发端口和 API 合同。正式代理切换采用可回滚配置:先同时发布两个入口,再切换默认链接到 `/admin/`;切换期间 `/admin-legacy/` 保持可访问。 + +Docker 验收包括前端镜像构建、PostgreSQL/Redis/API 启动、Alembic 升级、健康检查、两个管理入口、API 代理和刷新深链路。生产环境变量、密钥和现有部署配置不写入仓库或文档示例。 + +## 5. 文档同步 + +实现前或接口确认时同步以下契约文档: + +- `docs/home-api.md`:四组首页 CRUD 的 Vue 对接字段、排序和错误语义; +- `docs/admin-api-requirements.md`:踢下线、403、数据范围和响应契约; +- `docs/integration-workflow.md`:三端启动、正式代理和验收顺序; +- `docs/admin-business-function-matrix.md`:每个垂直切片的迁移、测试和人工验收记录。 + +不修改 `docs/public-api.md`,除非实际发现 Public API 字段发生兼容性变化。MiniAPP 本阶段只做契约回归检查,不整体重写。 + +## 6. 测试与验收闸门 + +采用测试先行和垂直切片交付。每一片先增加会失败的测试,再写实现,最后运行与影响范围匹配的验证。 + +### 后端 + +- 四组 CRUD 的鉴权、参数校验、响应格式、排序和审计测试; +- 会话创建、刷新轮换、退出、按用户踢下线、重复撤销、Redis 不可用测试; +- 401/403 区分测试; +- 五级数据范围的跨部门/子部门/本人矩阵测试; +- 旧 React 登录字段、Public API、既有 Admin API 兼容测试; +- Alembic 空库初始化、现有数据库升级与 Docker 健康检查。 + +### Vue 与 React + +- Vue TypeScript 检查、单元测试和生产构建; +- 四组首页 CRUD 的列表、表单、删除、排序、错误态和按钮权限; +- Vue 刷新、退出、踢下线、403、动态菜单; +- React 原有构建和登录刷新回归; +- 正式代理下 `/admin/`、`/admin-legacy/`、深链刷新和 `/api/` 转发。 + +### 人工验收顺序 + +1. 四组首页 CRUD; +2. 踢下线与 403; +3. 五级数据范围; +4. Docker/Redis/正式反向代理; +5. Public API 与 MiniAPP 回归; +6. 最后单独验收 OSS/媒体上传。 + +每个阶段完成后记录实际命令、结果、失败项和人工操作证据。任何一项未通过都不能把 Vue 标记为正式替代端。 + +## 7. 回滚与删除闸门 + +- 四组业务切片异常时,可保持 React 入口继续承载原页面,Vue 只回滚对应菜单/路由,不回滚数据库既有业务数据。 +- 反向代理异常时保留 `/admin-legacy/`,恢复默认入口到 legacy 配置即可回退。 +- 会话索引异常时拒绝管理请求并修复 Redis 索引,不允许切换到绕过会话校验的降级模式。 +- 本阶段不删除 React 源码、依赖、构建入口或 legacy 代理。 + +只有同时满足以下条件,并由用户明确确认,才启动 React 删除阶段: + +1. Vue 覆盖功能矩阵全部业务能力; +2. Public API 和现有 Admin API 无回归; +3. 角色、菜单、按钮权限、五级数据范围测试通过; +4. 登录刷新、退出、踢下线、Redis 异常通过; +5. Docker 和正式反向代理通过; +6. OSS/媒体上传通过; +7. 用户完成人工验收并明确确认删除旧 React。 + +## 8. 非目标 + +- 不整体重写 MiniAPP; +- 不引入 RuoYi 的代码生成、定时任务、监控、表单设计器等非核心模块; +- 不直接删除或重置业务数据; +- 不恢复 Alembic 已清理的无用 schema; +- 不把媒体上传提前到本阶段的首页 CRUD 验收之前; +- 不在本规格确认前改动生产环境变量、密钥和部署数据。