diff --git a/qy-lty-admin/.planning/phases/02-rbac-ai/02-RESEARCH.md b/qy-lty-admin/.planning/phases/02-rbac-ai/02-RESEARCH.md new file mode 100644 index 0000000..f2335d8 --- /dev/null +++ b/qy-lty-admin/.planning/phases/02-rbac-ai/02-RESEARCH.md @@ -0,0 +1,1037 @@ +# Phase 2:RBAC 收敛 + AI 模型页入口 - Research + +**Researched:** 2026-05-08 +**Domain:** 前端 RBAC 矩阵扩展 + Next.js 客户端页面入口控件 + shadcn Dialog 占位 +**Confidence:** HIGH(所有改动文件已直接 read 完整源码 + 全仓 grep 验证;不引入新依赖、不引入新外部服务) + +## Summary + +本 phase 是 Milestone v1.0「通用凭据槽位前端集成」的第二步:在 `lib/permissions.ts` 把 `'credential-slot'` 加入 `PermissionModule` union 与 `PERMISSION_MATRIX`(仅给「超级管理员」+「AI模型管理员」),并在 `app/ai-model/page.tsx` 的 `DashboardHeader` 子节点位置渲染受 `hasPermission('credential-slot')` 收敛的「凭据槽位」按钮 + 占位 Dialog。所有改动是**纯增量、可逆**:原有 13 模块 union 完整保留、其他 4 个角色数组逐字不动,`getModuleFromPath` 完全不动;新按钮 JSX 用 `hasPermission && }`) +- **点击行为**:本 phase 触发**占位空对话框**打开(基于 `components/ui/dialog.tsx`),对话框内容仅 DialogTitle + DialogDescription(中文文案"通用凭据槽位"+ 说明"对话框真实内容由 Phase 3 落地"),无表单 +- **对话框组件位置**:在 `app/ai-model/page.tsx` 内联(不抽到独立文件 —— Phase 3 会把对话框抽到 `components/ai-model/CredentialSlotDialog.tsx`) +- **状态管理**:用 `useState` 控制 dialog open 状态 + +#### 兼容性 / 不引入新依赖 + +- 沿用现有依赖:`components/ui/button.tsx`、`components/ui/dialog.tsx`、`lucide-react`、`lib/permissions.ts:hasPermission` +- 不引入新依赖(不动 lockfile,不跑 npm install) +- 不动 `lib/permissions.ts` 的其他函数(`getUserRole` / `hasPathPermission` 等) + +#### 修改记录 + +`qy-lty-admin/docs/修改记录.md` 顶部追加一条 Phase 2 条目: +- 文件路径:`lib/permissions.ts`、`app/ai-model/page.tsx` +- 修改类型:新增 / 修改 +- 跨项目联动:「无 — Phase 2 是纯前端 RBAC + UI 入口落地,不引入新跨项目契约;后端 commit 46d72b8 已建立的互引仍有效;Phase 3 引入实质 PUT 调用时若涉及新契约再评估」 + +### Claude's Discretion + +- 入口 Button 的具体 `variant`(outline / default / secondary)—— planner 看现有页面其他按钮风格选最一致的 +- 入口 Button 的 `size`(默认 / sm / lg)—— 同上 +- 占位对话框的 DialogContent `className` 大小 —— 用默认即可 +- 是否给 Button 加 tooltip(KeyRound 图标语义不够明显时)—— 推荐加,让运营秒懂 + +### Deferred Ideas (OUT OF SCOPE) + +- **编辑对话框真实表单**(RHF + Zod + 提交逻辑) — Phase 3 / CRED-FE-04 +- **Sonner toast 反馈** — Phase 3 / CRED-FE-05 +- **`components/ai-model/CredentialSlotDialog.tsx` 抽离** — Phase 3 +- **真实后端 PUT 调用** — Phase 3 +- **端到端联调** — Phase 3 后 + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CRED-FE-02 | RBAC 模块声明:`lib/permissions.ts` 加入 `credential-slot` 模块 key(`PermissionModule` 类型扩充);`PERMISSION_MATRIX` 把该模块分配给"超级管理员"和"AI模型管理员"两个角色;`getModuleFromPath()` 不需要新映射 | 已读 `lib/permissions.ts` 完整 123 行;下方「`lib/permissions.ts` 现状」段给出 union 13 项 + 6 个角色完整数组 + `getModuleFromPath` 当前 13 条路径映射表(`/ai-model` → `'ai-model'`,确认不动) | +| CRED-FE-03 | `/ai-model` 页面入口:在合适位置渲染"凭据槽位"按钮;仅当 `hasPermission('credential-slot')` 为 true 时可见;点击触发对话框打开 | 已读 `app/ai-model/page.tsx` 完整 446 行;下方「`app/ai-model/page.tsx` 现状」段给出 `DashboardHeader` 当前结构 + 现有 `添加新模型` 按钮风格 + 推荐插入点(line 12-15 的 DashboardHeader children 区域)+ Button variant 推荐 | + + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| RBAC 模块声明(union + 矩阵) | 工具层(`lib/permissions.ts`) | — | 这是项目宪法约定的 RBAC 单一来源;扩展 union literal 与角色数组属于纯类型/数据改动,不涉及 UI 或 API | +| 入口可见性判断 | 组件层(`app/ai-model/page.tsx` 客户端组件) | 工具层(`hasPermission`) | 与 `components/sidebar.tsx` 当前模式一致:客户端 `useState(mounted)` + `hasPermission(module)` 控制 DOM 渲染(不在 SSR 阶段读 localStorage) | +| 占位对话框状态机 | 组件层(`app/ai-model/page.tsx` 内联) | UI 原子层(`components/ui/dialog.tsx`) | shadcn Dialog 的 controlled mode(`open` + `onOpenChange`)让对话框开关由 `useState` 单源驱动;对话框内容此 phase 内联,Phase 3 才抽离 | +| 修改记录追加 | 文档层(`docs/修改记录.md`) | — | CLAUDE.md 强制:每次修改必须在同一会话追加到顶部 | + +## Standard Stack + +### Core(已在 deps,本 phase 沿用) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| react | 19 | useState | Phase 2 仅用 `useState` `[CITED: package.json]` | +| next | 15.2.4 | App Router | `app/ai-model/page.tsx` 已是 App Router 页面(添加 `"use client"` 后可用 hooks)`[CITED: package.json + STACK.md]` | +| @radix-ui/react-dialog | latest | Dialog primitive | `components/ui/dialog.tsx` 已封装 shadcn 风格 `[VERIFIED: components/ui/dialog.tsx line 4]` | +| class-variance-authority | latest | Button variants | `components/ui/button.tsx` 用 cva 定义 6 个 variant + 4 个 size `[VERIFIED: components/ui/button.tsx line 3]` | +| lucide-react | ^0.454.0 | 图标库(含 KeyRound) | `[VERIFIED: package.json line 50 + yarn.lock line 1493]` —— `KeyRound` 是 lucide-react 240+ 图标之一 `[CITED: lucide.dev/icons/key-round]` | + +### Supporting(项目已有,本 phase 引用) + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `@/lib/permissions` | local | `hasPermission(module)` | 入口可见性判断 | +| `@/components/ui/button` | local(shadcn 复制粘贴) | Button + variant | 入口按钮样式 | +| `@/components/ui/dialog` | local(shadcn 复制粘贴) | Dialog + Header/Title/Description | 占位对话框 | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| 内联 Dialog 在 `app/ai-model/page.tsx` | 立即抽到 `components/ai-model/CredentialSlotDialog.tsx` | CONTEXT.md 显式锁定**内联**(Phase 3 才抽离)—— 不要在 Phase 2 提前抽离 | +| KeyRound | Lock / Settings | CONTEXT.md 显式说"如果不可用降级到 Lock / Settings"。`KeyRound` 在 lucide-react 0.454.0 中确实存在 `[CITED: lucide.dev/icons/key-round]` 且 ^0.454.0 已在 deps,无需降级 | +| Card 入口 | Button 入口 | CONTEXT.md 锁定 **Button**(最简) | + +**Installation:** + +无需安装 —— Phase 2 不引入任何新依赖。`KeyRound` 是 lucide-react 0.454.0 的标准图标(lucide 从 0.298+ 即已包含 `KeyRound`)`[CITED: lucide.dev/icons/key-round]`。 + +**Version verification:** + +```bash +# 已通过仓库内文件确认,无需联网 +grep -n '"lucide-react"' package.json # → "lucide-react": "^0.454.0" +grep -rn "from \"lucide-react\"" components/ app/ # → 50+ 个文件已 import lucide-react +``` + +`[VERIFIED: package.json line 50]` lucide-react ^0.454.0 已锁定。 + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Browser(已认证用户访问 /ai-model) │ +└──────────────────────────────────────────────────────────────────────┘ + │ + │ 1. middleware.ts cookie auth_token check(已存在) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ DashboardShell(components/dashboard-shell.tsx) │ +│ - hasPathPermission(pathname) → /ai-model 映射到 module 'ai-model' │ +│ - 'ai-model' 在「超级管理员」「AI模型管理员」两个角色的矩阵中 │ +└──────────────────────────────────────────────────────────────────────┘ + │ + │ 2. 路径级权限通过 → 渲染子内容 + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ app/ai-model/page.tsx("use client" 转换后) │ +│ │ +│ DashboardHeader heading="大模型管理"> │ +│ [现有] (pink-purple gradient) │ +│ [新增] {hasPermission('credential-slot') && ( │ +│ │ +│ )} │ +│ │ +│ │ +│ ... Tabs ... │ +│ │ +│ [新增] │ +│ │ +│ │ +│ 通用凭据槽位 │ +│ │ +│ 对话框真实内容由 Phase 3 落地 │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ + │ + │ 3. hasPermission('credential-slot') 内部 + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ lib/permissions.ts(本 phase 改动文件之一) │ +│ getUserRole() → 读 localStorage.user_role → RoleName │ +│ getAllowedModules() → PERMISSION_MATRIX[role] │ +│ hasPermission('credential-slot') → 数组 includes 检查 │ +│ │ +│ 改动: │ +│ - PermissionModule union +1: 'credential-slot' │ +│ - 超级管理员 数组 +1: 'credential-slot' │ +│ - AI模型管理员 数组 +1: 'credential-slot' │ +│ - 内容管理员/卡牌管理员/查看者/管理员:不变 │ +│ - getModuleFromPath:不变 │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure + +``` +qy-lty-admin/ +├── lib/ +│ └── permissions.ts # ← 改动:union +1, 矩阵 +2 角色 +├── app/ +│ └── ai-model/ +│ └── page.tsx # ← 改动:"use client", useState, 入口 Button, 占位 Dialog +└── docs/ + └── 修改记录.md # ← 改动:顶部追加 Phase 2 条目 +``` + +### Pattern 1:客户端 mounted 守卫的 hasPermission + +**What:** 由于 `hasPermission()` 内部走 `localStorage.getItem("user_role")`,SSR 阶段 `typeof window === "undefined"` 会一律返回 `"查看者"` 角色,可能造成水合不匹配。`components/sidebar.tsx` 已用 `mounted` 守卫解决。 + +**When to use:** `app/ai-model/page.tsx` 加入 `"use client"` 后,对入口可见性的 `hasPermission` 调用 **强烈建议**走 mounted 模式,避免水合闪烁。 + +**Example(来自 `components/sidebar.tsx:83-104`):** + +```tsx +// Source: components/sidebar.tsx +"use client" +import { useState, useEffect } from "react" +import { hasPermission } from "@/lib/permissions" + +export function Sidebar() { + const [mounted, setMounted] = useState(false) + useEffect(() => { setMounted(true) }, []) + + const visibleAiItems = mounted + ? aiMenuItems.filter((item) => hasPermission(item.module)) + : [] + // ... 同理过滤其他菜单组 +} +``` + +`[VERIFIED: components/sidebar.tsx line 83-104]` + +### Pattern 2:shadcn Dialog controlled mode + +**What:** `components/ui/dialog.tsx` 把 `Dialog = DialogPrimitive.Root` 直通导出,因此支持 Radix Dialog 的 `open` + `onOpenChange` controlled props。 + +**When to use:** 当点击逻辑由父组件持有(本 phase:点击 Button → setOpen(true)),用 controlled mode;不要用 `` 包按钮(会在 DOM 中嵌套)。 + +**Example:** + +```tsx +// Source: components/ui/dialog.tsx + Radix Dialog API +"use client" +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { KeyRound } from "lucide-react" +import { hasPermission } from "@/lib/permissions" + +const [isCredentialDialogOpen, setIsCredentialDialogOpen] = useState(false) + +return ( + <> + {hasPermission("credential-slot") && ( + + )} + + + + + 通用凭据槽位 + + 对话框真实内容由 Phase 3 落地 + + + + + +) +``` + +`[VERIFIED: components/ui/dialog.tsx line 9 + 111-122 export 列表]` + +### Anti-Patterns to Avoid + +- **不要用 DialogTrigger 包按钮**:DialogTrigger 是 uncontrolled 模式语法糖;本 phase 用 controlled mode(`open` + `onOpenChange`)更清晰,也方便 Phase 3 加"打开时自动 GET"逻辑。 +- **不要在 SSR 阶段调 `hasPermission`**:会读不到 localStorage 一律降级到「查看者」,导致按钮总是不渲染、即使是超管也看不到。务必走 `useEffect` mounted 守卫,复用 `components/sidebar.tsx` 模式。 +- **不要直接读 `localStorage.user_role` 或 `localStorage.is_superuser`**:CONTEXT.md 锁定走 `hasPermission('credential-slot')`,不直接读字符串。 +- **不要把 `'credential-slot'` 加进其他 4 个角色数组**:内容管理员 / 卡牌管理员 / 查看者 / 管理员 的数组**逐字不动**。 +- **不要动 `getModuleFromPath`**:路径级映射保持现状,凭据槽位是 `/ai-model` 页面内嵌子能力。 +- **不要新建独立 Dialog 组件文件**:内联在 `app/ai-model/page.tsx` 里;Phase 3 才抽到 `components/ai-model/CredentialSlotDialog.tsx`。 + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| 角色到模块的权限矩阵 | 自己写 if/else 字符串比较 | `hasPermission('credential-slot')` | 单一来源、TS literal 校验、避免不同页面权限规则漂移 | +| 模态框开关动画 | 自己写 transition | shadcn `` + Radix data-state animation | `components/ui/dialog.tsx` 已含 `data-[state=open]:animate-in` 等 Tailwind 动画工具类 `[VERIFIED: dialog.tsx line 24-44]` | +| 按钮样式 variant | 自己写 className 组合 | `}` 包裹。 + +**Warning signs:** Next.js dev console 出现 `Hydration failed` warning,或者刷新页面瞬间按钮闪一下消失再出现。 + +### Pitfall 3:把按钮放在 DashboardHeader 之外 + +**What goes wrong:** 现有 `DashboardHeader` 已经按 children 渲染右侧 actions(line 11-16 现有「添加新模型」按钮就放在 children 位置)。如果新按钮放在 `` 后、`` 前,会形成游离的工具栏行,与现有视觉层级不一致。 + +**How to avoid:** 把"凭据槽位"按钮放到 `DashboardHeader` children 内,在「添加新模型」按钮**右侧**(同行)。 + +**Warning signs:** 视觉上按钮浮空、与页面顶部留白割裂。 + +### Pitfall 4:Dialog 嵌在 DashboardHeader children 内 + +**What goes wrong:** 如果把 `` JSX 也放进 `DashboardHeader` children,会与现有 buttons 同级,引发 portal 边界问题(Radix Portal 会绕到 body 末尾,理论上不影响渲染但破坏组件树可读性)。 + +**How to avoid:** `` 作为 `` 的**最后一个**兄弟节点(在 `` 之后、`` 之前),与 Header 内的 trigger Button 用 `useState` 通过 `open` prop 联动。 + +### Pitfall 5:忘记给 `[ASSUMED]` 加 `"use client"` 后的 import 顺序 + +**What goes wrong:** Next.js App Router 要求 `"use client"` **必须**是文件第一行(在所有 import 之前)。 + +**How to avoid:** 文件结构应是: + +```tsx +"use client" // line 1 + +import { useState } from "react" // line 2-x +import { ... } from "..." +// ... +``` + +## Code Examples + +### `lib/permissions.ts` 改动 patch(提示式,非最终代码) + +```ts +// Source: lib/permissions.ts (current state) +// Before: PermissionModule union 13 项 +export type PermissionModule = + | "dashboard" + | "users" + | "permissions" + | "ai-model" + | "outfits" + | "props" + | "home-decor" + | "food" + | "songs" + | "dances" + | "achievements" + | "affinity" + | "settings"; + +// After: PermissionModule union 14 项(追加 'credential-slot') +export type PermissionModule = + | "dashboard" + | "users" + | "permissions" + | "ai-model" + | "outfits" + | "props" + | "home-decor" + | "food" + | "songs" + | "dances" + | "achievements" + | "affinity" + | "settings" + | "credential-slot"; // ← 新增 + +// Before: 超级管理员 + AI模型管理员 +const PERMISSION_MATRIX: Record = { + 超级管理员: [ + "dashboard", "users", "permissions", "ai-model", + "outfits", "props", "home-decor", "food", + "songs", "dances", "achievements", "affinity", "settings", + ], + // ... + AI模型管理员: [ + "dashboard", "ai-model", + ], + // ... +}; + +// After: 两个角色尾部追加 'credential-slot',其他 4 角色不变 +const PERMISSION_MATRIX: Record = { + 超级管理员: [ + "dashboard", "users", "permissions", "ai-model", + "outfits", "props", "home-decor", "food", + "songs", "dances", "achievements", "affinity", "settings", + "credential-slot", // ← 新增 + ], + 内容管理员: [ // ← 不变 + "dashboard", "outfits", "props", "home-decor", "food", + "songs", "dances", "achievements", "affinity", + ], + AI模型管理员: [ + "dashboard", "ai-model", + "credential-slot", // ← 新增 + ], + 卡牌管理员: [ // ← 不变 + "dashboard", "outfits", "props", "home-decor", "food", + ], + 查看者: [ // ← 不变 + "dashboard", + ], + 管理员: [ // ← 不变 + "dashboard", + ], +}; +``` + +`[VERIFIED: lib/permissions.ts line 21-60]` + +### `app/ai-model/page.tsx` 改动骨架 + +```tsx +// Source 模板:app/ai-model/page.tsx 现状(line 1-16)+ components/sidebar.tsx mounted 模式 +"use client" // ← 新增 line 1(顶置) + +import { useState, useEffect } from "react" // ← 新增 +import { DashboardShell } from "@/components/dashboard-shell" +import { DashboardHeader } from "@/components/dashboard-header" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" // ← 新增 +import { + Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User, + KeyRound, // ← 新增 +} from "lucide-react" +import { hasPermission } from "@/lib/permissions" // ← 新增 + +export default function AIModelPage() { + const [mounted, setMounted] = useState(false) + const [isCredentialDialogOpen, setIsCredentialDialogOpen] = useState(false) + + useEffect(() => { setMounted(true) }, []) + + return ( + + + + {/* 凭据槽位入口(受 RBAC 收敛) */} + {mounted && hasPermission("credential-slot") && ( + + )} + + + + {/* ...原有 Tabs 内容完全保留... */} + + + {/* 占位 Dialog(Phase 3 替换为真实表单) */} + + + + 通用凭据槽位 + + 对话框真实内容由 Phase 3 落地 + + + + + + ) +} +``` + +`[VERIFIED: 模板基于 app/ai-model/page.tsx 当前 line 1-16 + components/sidebar.tsx line 83-104 mounted 模式]` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| 在 React 组件里直接 `localStorage.getItem("user_role") === "超级管理员"` | 通过 `hasPermission(module)` | 项目初始化即如此 | 单一来源、TS literal 校验、避免硬编码 | +| 用 DialogTrigger 包 Button(uncontrolled) | controlled mode:`open` + `onOpenChange` + 父级 `useState` | 与 Phase 3 拉数据时机对齐 | 父组件能在打开瞬间触发 GET,关闭时清表单 | + +**Deprecated/outdated:** 无 —— 项目用的就是 React 19 + Next 15 当前推荐模式。 + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `KeyRound` 在 lucide-react 0.454.0 中存在 | Standard Stack / Code Examples | 编译时 import 失败;CONTEXT.md 已显式给出降级路径 `Lock` / `Settings`;plan 阶段需在 task 验证步加一句 `import { KeyRound } from "lucide-react"` 编译通过即认定可用,否则降级 `Lock` | +| A2 | `app/ai-model/page.tsx` 加 `"use client"` 后不会破坏 SSR 元数据或 metadata API | Common Pitfalls Pitfall 1 | 该页面无 `export const metadata`、无 `generateMetadata`,纯静态卡片转 client 仅性能影响(浏览器多 ship 几 KB),不破坏功能;项目内 `app/users/page.tsx`、`app/songs/page.tsx` 等大量页面已在 client 化 | +| A3 | `DashboardHeader` 的 children 容器布局允许 2 个并排 Button(不会强制单 Button) | Pitfall 3 | 未直接 read `components/dashboard-header.tsx`;但若 children 是 `flex` row 容器(shadcn 风格惯例),多个 Button 会自然横排;若不是,可包一层 `
` 即可。Plan 阶段建议先 read `dashboard-header.tsx` 再下结论 | + +## Open Questions + +1. **`components/dashboard-header.tsx` 的 children slot 布局是否原生支持多 Button 横排?** + - 已知:现有 `app/ai-model/page.tsx` 仅传 1 个「添加新模型」Button 进 children,未测试多 Button 场景 + - 不清楚:children 容器是 `flex` 还是单 slot + - 推荐处理:planner 让 task 第一步 `Read components/dashboard-header.tsx`,若 children 不是 flex 容器,则在 `app/ai-model/page.tsx` 用 `
` 包两个 Button 后再放进 children + +2. **加 `"use client"` 后 `app/ai-model/page.tsx` 是否需要 import 调整?** + - 已知:现有 import 块(line 1-7)只 import `DashboardShell` / `DashboardHeader` / `Button` / `Card` / `Tabs` / lucide 图标,全是项目 client 兼容组件 + - 不清楚:无(已确认) + - 推荐处理:直接加 `"use client"` 在 line 1,原 import 全部保留 + 新增 `useState/useEffect`、`Dialog 子组件`、`KeyRound`、`hasPermission` + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Node.js / npm | dev / build | ✓(前 phase 已用) | — | — | +| lucide-react | KeyRound 图标 | ✓ | ^0.454.0 `[VERIFIED: package.json line 50]` | Lock / Settings 同包内图标 | +| @radix-ui/react-dialog | Dialog primitive | ✓(已被 components/ui/dialog.tsx 使用) | latest `[CITED: STACK.md]` | 无需 | +| react | useState/useEffect | ✓ | 19 `[CITED: STACK.md]` | 无需 | + +**Missing dependencies with no fallback:** 无 + +**Missing dependencies with fallback:** 无 + +**结论:** Phase 2 不引入新依赖,所有所需运行时已就绪。 + +## Validation Architecture + +> 项目目前未配置测试框架(`STRUCTURE.md` 第 176-177 行:「未检测到测试文件;测试尚未配置」);Milestone v1.0 不计划引入 Vitest(见 REQUIREMENTS.md 候选优先级第 7 条「中」级,明确**本期不消化**)。 +> 因此 Phase 2 走**编译时 + grep 静态校验 + 浏览器手动登录验证**的轻量回路,与 Phase 1 一致(Phase 1 已用 `npm run lint` + `npx tsc --noEmit` 双重验证作为 plan 01-02 的核心验证步)。 + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | 无单元测试框架 —— 走 `tsc --noEmit` + `next lint` + grep | +| Config file | `tsconfig.json`(strict mode) | +| Quick run command | `npm run lint`(实际是 `next lint`) | +| Full suite command | `npx tsc --noEmit && npm run lint` | +| Phase gate | `npx tsc --noEmit` exit 0、`grep` 11 条 specifics 全命中 | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CRED-FE-02 | `PermissionModule` 含 `'credential-slot'` literal | static-grep | `grep -nE "['\"]credential-slot['\"]" lib/permissions.ts \| wc -l`(期望 ≥3:union literal +「超级管理员」数组 +「AI模型管理员」数组) | ✅ lib/permissions.ts 已存在 | +| CRED-FE-02 | TS 编译通过(union 扩展无碎片) | compile | `npx tsc --noEmit` | ✅ 全仓 | +| CRED-FE-02 | 其他角色不含 `'credential-slot'` | static-grep(反向) | grep + 视觉核对:`grep -nE "credential-slot" lib/permissions.ts` 结果共 4 处(type union literal + 2 角色数组 + 可能 1 处注释/空行);不含「内容管理员」「卡牌管理员」「查看者」「管理员」字段下方数组中 | ✅ | +| CRED-FE-02 | `getModuleFromPath('/ai-model')` 行为不变 | structural-grep | `grep -n '"ai-model": "ai-model"' lib/permissions.ts`(期望 1 处命中且周边代码不变) | ✅ | +| CRED-FE-03 | 页面含 `"凭据槽位"` 文本 | static-grep | `grep -n "凭据槽位" app/ai-model/page.tsx` ≥1(按钮文案)+ ≥1(DialogTitle) | ✅ app/ai-model/page.tsx 已存在 | +| CRED-FE-03 | 页面含 `KeyRound` import 与使用 | static-grep | `grep -n "KeyRound" app/ai-model/page.tsx` ≥2(import + JSX) | ✅ | +| CRED-FE-03 | 页面含 `hasPermission("credential-slot")` 包裹 | static-grep | `grep -nE 'hasPermission\(["\\']credential-slot' app/ai-model/page.tsx` ≥1 | ✅ | +| CRED-FE-03 | 页面含 Dialog 占位(`通用凭据槽位`) | static-grep | `grep -n "通用凭据槽位" app/ai-model/page.tsx` ≥1 | ✅ | +| CRED-FE-03 | 浏览器实测:超级管理员看见入口、查看者看不见 | manual-only | 登录两类账户访问 `/ai-model` 视觉核对 | ✅ | + +### Sampling Rate + +- **Per task commit:** `npx tsc --noEmit`(≤ 30s)+ `grep` 单点校验(< 1s) +- **Per wave merge:** `npm run lint && npx tsc --noEmit`(≤ 60s) +- **Phase gate:** 全部 specifics 11 条 grep 命中 + `npx tsc --noEmit` exit 0 + 修改记录顶部含 Phase 2 条目 + +### Wave 0 Gaps + +- 无 —— 项目无单元测试,且本期不引入 Vitest(明确 deferred)。沿用 Phase 1 的「`tsc --noEmit` + `lint` + grep + 浏览器手动核对」回路即可。 + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | no(已由 AUTH-01..06 + middleware 覆盖;Phase 2 不动) | — | +| V3 Session Management | no(同上) | — | +| V4 Access Control | **yes**(核心) | `lib/permissions.ts:hasPermission` 角色矩阵 + `DashboardShell.hasPathPermission` 路由级;Phase 2 是给该矩阵新增一个 module key | +| V5 Input Validation | no(Phase 2 无表单输入;占位 Dialog 不收集任何输入) | — | +| V6 Cryptography | no | — | + +### Known Threat Patterns for Next.js 15 + 客户端 RBAC + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| 仅前端校验导致越权(修改 localStorage.user_role 即可绕过) | Elevation of Privilege | **后端独立校验**(PERM-06,CONCERNS.md 极高严重级;本期 Active 范围内不消化,但 Phase 3 实际调用 `/v1/admin/credential-slot/` PUT 时后端必须重新校验角色) | +| 隐藏按钮但能直接 fetch API | Information Disclosure | 同上,靠后端 RBAC 闭环;Phase 2 内的"DOM 中完全不存在"只是 UX 礼貌,不是安全边界 | +| Hydration mismatch 导致按钮短暂渲染给所有人 | Information Disclosure(弱) | mounted 守卫模式(见 Pitfall 2) | + +**关键提醒:** `lib/permissions.ts` 的 RBAC 是**前端 UI 层**保护,**绝不是真实安全边界**。CRED-FE-02 + CRED-FE-03 是 UI 层「可见性收敛」,最终安全闭环由后端在 Phase 3 实际调用时由 `qy_lty` Django 端的 admin 中间件 + 视图级权限装饰器完成。Phase 2 的 success criteria 只校验"DOM 中是否渲染"和"调用 hasPermission 时返回是否正确",**不**校验后端权限闭环。 + +## Project Constraints (from CLAUDE.md) + +- **沟通中文**:用户偏好中文沟通;注释、commit message、面向用户的回答**必须**中文。RESEARCH.md / PLAN.md 等文档已强制中文(见 user memory `feedback_gsd_chinese.md`)。 +- **修改记录强制**:每次代码改动**必须**在同一会话内追加到 `qy-lty-admin/docs/修改记录.md` 顶部(最新在前),按头部「修改格式说明」格式(**日期 / 文件路径 / 修改类型 / 修改内容 / 修改原因**)。 +- **跨仓库联动单写**:本仓库**仅**记录前端改动;后端改动写在 `qy_lty/docs/修改记录.md`;联动改动两边各写一条相互引用,**不混在同一条**。 +- **包管理器不混用**:项目同时存在 `package-lock.json` + `pnpm-lock.yaml` + `yarn.lock`,本期 v1.0 不收敛(候选优先级第 4 条「高」级,本期不消化);本 phase **不要**新增依赖、**不要**触发任何包管理器命令。 +- **shadcn 复制粘贴模式**:`components/ui/` 是源码副本,不是 npm 包;可直接修改但本 phase **不要**改 `components/ui/dialog.tsx` 或 `components/ui/button.tsx`。 +- **生产构建配置**:`next.config.mjs` 当前 `eslint.ignoreDuringBuilds` + `typescript.ignoreBuildErrors` 都是 true(候选优先级第 3 条「高」级,本期不消化);这意味着 lint/类型错误**不会**阻塞 build —— 但 `npx tsc --noEmit` 仍会显式报错,本 phase 必须以此为质量门。 + +## 现状证据:`lib/permissions.ts`(完整结构) + +`[VERIFIED: lib/permissions.ts line 1-123, 全文已 read]` + +### `PermissionModule` union 当前 13 项(line 21-34) + +```ts +export type PermissionModule = + | "dashboard" + | "users" + | "permissions" + | "ai-model" + | "outfits" + | "props" + | "home-decor" + | "food" + | "songs" + | "dances" + | "achievements" + | "affinity" + | "settings"; +``` + +### `RoleName` union(line 18) + +```ts +export type RoleName = "超级管理员" | "内容管理员" | "AI模型管理员" | "卡牌管理员" | "查看者" | "管理员"; +``` + +**注意:** `RoleName` 共 **6** 个角色(CONTEXT.md 说"5+1"指业务上 5 个 + 1 个 fallback「管理员」)。 + +### `PERMISSION_MATRIX` 各角色当前模块列表(line 37-60) + +| 角色 | 当前模块数 | 完整数组 | Phase 2 改动 | +|------|-----------|----------|-------------| +| **超级管理员** | 13 | `["dashboard", "users", "permissions", "ai-model", "outfits", "props", "home-decor", "food", "songs", "dances", "achievements", "affinity", "settings"]` | **末尾追加 `"credential-slot"`** → 14 项 | +| 内容管理员 | 9 | `["dashboard", "outfits", "props", "home-decor", "food", "songs", "dances", "achievements", "affinity"]` | 不变(确认不含 `"credential-slot"`) | +| **AI模型管理员** | 2 | `["dashboard", "ai-model"]` | **末尾追加 `"credential-slot"`** → 3 项 | +| 卡牌管理员 | 5 | `["dashboard", "outfits", "props", "home-decor", "food"]` | 不变 | +| 查看者 | 1 | `["dashboard"]` | 不变 | +| 管理员(fallback) | 1 | `["dashboard"]` | 不变 | + +### `hasPermission(module)` 实现(line 85-87) + +```ts +export function hasPermission(module: PermissionModule): boolean { + return getAllowedModules().includes(module); +} +``` + +底层链路:`hasPermission` → `getAllowedModules()` → `getUserRole()` → `localStorage.getItem("user_role")` →(找不到则 fallback「查看者」)→ `PERMISSION_MATRIX[role]`。 + +### `getModuleFromPath(pathname)` 实现(line 92-113) + +```ts +export function getModuleFromPath(pathname: string): PermissionModule | null { + const segment = pathname.replace(/^\//, "").split("/")[0]; + const pathMap: Record = { + "": "dashboard", + "ai-model": "ai-model", // ← `/ai-model` → 'ai-model' module,本 phase 不动 + "outfits": "outfits", + "props": "props", + "home-decor": "home-decor", + "food": "food", + "songs": "songs", + "dances": "dances", + "achievements": "achievements", + "affinity": "affinity", + "users": "users", + "permissions": "permissions", + "settings": "settings", + }; + return pathMap[segment] ?? null; +} +``` + +**确认:** `getModuleFromPath('/ai-model')` 当前返回 `'ai-model'`(line 98),凭据槽位是 `/ai-model` 子能力 → **本 phase 不动这个映射**;`pathMap` 键值对完全保持 13 条不变。 + +### Phase 2 不动的导出函数 + +- `getUserRole()` line 65-72 +- `getAllowedModules()` line 77-80 +- `hasPathPermission(pathname)` line 118-122 + +`[VERIFIED: 全文已 read,所有 export 函数确认]` + +## 现状证据:`app/ai-model/page.tsx`(关键结构与插入点) + +`[VERIFIED: app/ai-model/page.tsx line 1-446, 全文已 read]` + +### 当前文件性质 + +- **未声明 `"use client"`**(line 1-7 直接是 import)→ 当前是 **Server Component**。Phase 2 必须加 `"use client"` 到 line 1(在所有 import 之前)。 + +### 头部结构(line 1-16) + +```tsx +import { DashboardShell } from "@/components/dashboard-shell" // line 1 +import { DashboardHeader } from "@/components/dashboard-header" // line 2 +import { Button } from "@/components/ui/button" // line 3 +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" // line 4 +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" // line 5 +import { Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User } from "lucide-react" // line 6 + +export default function AIModelPage() { // line 8 + return ( // line 9 + // line 10 + // line 11 + // line 15 + // line 16 + // line 17 (空行) + // line 18 +``` + +### 现有按钮风格(line 12-15 + 同文件多处) + +页面内 Button 风格分两类: + +| 风格 | className/variant | 出现位置 | 用途 | +|------|------------------|---------|-----| +| Primary(粉紫渐变) | `className="bg-gradient-to-r from-pink-500 to-purple-600 ..."` | line 12-15("添加新模型")、line 132-135(同文案)、line 232-234、line 339-341、line 436-438 | 主要 CTA | +| Outline | `variant="outline"` + 各种 hover 色 | line 74-80("查看详情")、line 96-103("设为主要")、line 184-192("编辑人格设定")、line 285-292("试听示例")、line 322-330、line 384-389、line 421-427、line 432-434 | 次要操作、详情、编辑入口 | + +### "凭据槽位"按钮**最佳插入点** + +**插入位置:** `app/ai-model/page.tsx` **line 15 之后、line 16 `` 之前**(即作为 `DashboardHeader` children 的第二个子节点,紧跟「添加新模型」按钮) + +```tsx + + + {/* ↓↓↓ 在此处插入 line 16 之前 ↓↓↓ */} + {mounted && hasPermission("credential-slot") && ( + + )} + {/* ↑↑↑ 插入结束 ↑↑↑ */} + +``` + +**推荐 variant:** `variant="outline"` —— 与同页面所有"次要操作 / 详情 / 编辑"按钮(共 8+ 处)风格一致;不与"添加新模型"主 CTA 抢视觉权重,但点击区清晰。`size` 用默认(不传 `size` 即 `default`,与"添加新模型"同高 h-10)。 + +**Dialog 占位插入点:** line 442(`` 之后)、line 443(`` 之前)—— 作为 `` children 的最后一个兄弟节点: + +```tsx + + {/* ↓↓↓ 在此处插入 ↓↓↓ */} + + + + 通用凭据槽位 + 对话框真实内容由 Phase 3 落地 + + + + {/* ↑↑↑ 插入结束 ↑↑↑ */} + +``` + +### 现有 import 链(line 1-6)— 需新增的 imports + +| 新增 import | 目的 | +|------------|------| +| `"use client"` 指令(line 1) | 启用 hooks | +| `import { useState, useEffect } from "react"` | mounted 守卫 + dialog 开关 | +| 在 line 6 lucide import 末尾追加 `KeyRound` | 凭据槽位按钮图标 | +| `import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"` | 占位对话框 | +| `import { hasPermission } from "@/lib/permissions"` | RBAC 收敛判断 | + +### 现有 useState / Dialog 用法在本文件中 + +- **本文件没有任何 `useState`**(确认 grep 0 命中 `useState` in `app/ai-model/page.tsx`)—— 当前是纯 server component。Phase 2 引入的两个 useState(`mounted`、`isCredentialDialogOpen`)是该文件**首次** state 使用。 +- **本文件没有任何 `` 用法** —— Phase 2 新增的占位 Dialog 是该文件**首次** Dialog 使用。 + +## 现状证据:`components/ui/dialog.tsx`(API 验证) + +`[VERIFIED: components/ui/dialog.tsx line 1-123, 全文已 read]` + +### Export 列表(line 111-122) + +```ts +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} +``` + +确认 CONTEXT.md 提及的 5 个子组件 + 2 个补充组件均存在。 + +### Controlled mode 支持验证 + +- **line 9:** `const Dialog = DialogPrimitive.Root` —— `Dialog` 直通 Radix `DialogPrimitive.Root` +- Radix `Dialog.Root` 的 controlled props: + - `open?: boolean` —— 当前是否打开 + - `onOpenChange?: (open: boolean) => void` —— 状态变更回调(点 X、点遮罩、ESC 都会触发) + - `defaultOpen?: boolean` —— uncontrolled 默认值(本 phase 不用) +- 用法:`` 完全支持 `[CITED: radix-ui.com/primitives/docs/components/dialog#root]` + +### `DialogContent` props 自动 forward + +`DialogContent`(line 32-54)通过 `React.ComponentPropsWithoutRef` 透传所有 Radix props,并自带: +- `` 包装(line 36 + 52) +- `` 自动渲染(line 37) +- 右上角 X 按钮 ``(line 47-50)—— **占位 Dialog 不需要额外加关闭按钮,X 已自带** + +### `DialogTitle` / `DialogDescription` 是必填可访问性元素 + +Radix Dialog 要求每个 DialogContent 内有 DialogTitle,否则 dev 控制台会有 a11y warning。CONTEXT.md 锁定的"DialogTitle 通用凭据槽位 + DialogDescription 说明"刚好满足。 + +## 现状证据:`components/ui/button.tsx`(API 验证) + +`[VERIFIED: components/ui/button.tsx line 1-57, 全文已 read]` + +### `variant` 选项(line 11-21) + +| Variant | className | 用途 | +|---------|-----------|-----| +| `default` | `bg-primary text-primary-foreground hover:bg-primary/90` | 主 CTA(默认) | +| `destructive` | `bg-destructive ...` | 删除/危险操作 | +| **`outline`** | `border border-input bg-background hover:bg-accent hover:text-accent-foreground` | **次要操作(推荐用于凭据槽位入口)** | +| `secondary` | `bg-secondary ...` | 次级强调 | +| `ghost` | `hover:bg-accent ...` | 浅色背景按钮(如 Sidebar 菜单) | +| `link` | `text-primary underline-offset-4 hover:underline` | 文字链接式 | + +### `size` 选项(line 22-27) + +| Size | className | +|------|-----------| +| `default` | `h-10 px-4 py-2`(推荐用于本 phase) | +| `sm` | `h-9 rounded-md px-3` | +| `lg` | `h-11 rounded-md px-8` | +| `icon` | `h-10 w-10` | + +### Default variant + size(line 28-32) + +```ts +defaultVariants: { + variant: "default", + size: "default", +}, +``` + +→ 不传 `variant` / `size` 即默认 default + default。本 phase 显式传 `variant="outline"`、不传 size(即 default `h-10`)。 + +### `asChild` 支持(line 39 + 43-50) + +`asChild={true}` 用 Radix Slot 把 className 合并到子元素(用于 ``)。**本 phase 不需要 asChild**(按钮直接 `onClick` 调 setState)。 + +## 现状证据:`KeyRound` 图标可用性 + +`[VERIFIED: 全仓 grep + package.json + lucide.dev]` + +- 全仓 grep `KeyRound`:**0** 命中(除 CONTEXT.md 自身的 3 处提及)。 +- 全仓 grep `lucide-react`:**70+** 文件 import lucide-react(confirmed 在 deps 中)。 +- `package.json` line 50:`"lucide-react": "^0.454.0"`,`yarn.lock` 与 `package-lock.json` 均一致。 +- lucide-react 图标库 240+ 标准图标,`KeyRound` 自 ~0.298 版本起即包含 `[CITED: lucide.dev/icons/key-round]`,0.454 必含。 +- **结论:** `KeyRound` 是 Phase 2 **首次引入**的 lucide 图标(非新依赖,是同一已有 npm 包内的另一个具名 export),不需要修改 lockfile / 重跑 install。 + +如 plan 阶段执行时编译失败(极低概率),降级路径 CONTEXT.md 已锁定:`Lock`(已被 `components/sidebar.tsx` line 21 使用)→ `Settings`(已被 `components/sidebar.tsx` line 18 使用)。 + +## 现状证据:现有 `hasPermission` 调用样板 + +`[VERIFIED: components/sidebar.tsx + components/dashboard-shell.tsx]` + +### `components/sidebar.tsx`(菜单过滤样板,line 9 + 102-104) + +```tsx +import { hasPermission, getUserRole, type PermissionModule } from "@/lib/permissions" + +// ... + +const visibleAiItems = mounted ? aiMenuItems.filter((item) => hasPermission(item.module)) : [] +const visibleContentItems = mounted ? contentMenuItems.filter((item) => hasPermission(item.module)) : [] +const visibleSystemItems = mounted ? systemMenuItems.filter((item) => hasPermission(item.module)) : [] +``` + +**关键点:** `mounted` 守卫 + `hasPermission` 数组过滤;不直接读 `localStorage.user_role`。 + +### `components/dashboard-shell.tsx`(路径级样板,line 8 + 19-22) + +```tsx +import { hasPathPermission, getUserRole } from "@/lib/permissions" + +// ... + +const [allowed, setAllowed] = useState(null) +useEffect(() => { + setAllowed(hasPathPermission(pathname)) +}, [pathname]) +``` + +**关键点:** 用 `useState(null)` 三态(null = 未挂载)+ `useEffect` 做客户端水合后判定,避免 SSR 阶段误判。 + +### 推荐复用模式(凭据槽位按钮) + +```tsx +// 模板:components/sidebar.tsx 风格(mounted 守卫) +const [mounted, setMounted] = useState(false) +useEffect(() => { setMounted(true) }, []) + +// 渲染处: +{mounted && hasPermission("credential-slot") && ( + +)} +``` + +## 现状证据:修改记录格式模板 + +`[VERIFIED: docs/修改记录.md line 1-50]` + +### 头部「修改格式说明」(line 9-20) + +``` +### [日期] 修改简述 + +- **文件路径**: 相对于项目根目录的文件路径 +- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug +- **修改内容**: 具体修改了什么 +- **修改原因**: 为什么要做这个修改 +``` + +### Phase 1 既存条目(line 28-48,作为同 milestone 风格模板) + +`### [2026-05-08] Phase 1(前端)凭据槽位 API 客户端` 含: + +- **配套服务端 Phase**:跨链接到 `../qy_lty/.planning/phases/02-admin-rest/`(已落地,commit 46d72b8) +- **覆盖前端需求**:CRED-FE-01 +- **文件路径** 子段含多个文件分别标注「新增 / 修改」 +- **修改类型 / 修改内容 / 修改原因** 三段式 +- **跨项目联动**:明确说明本期 phase 是否引入新跨项目契约 +- **服务端联动**:补充字段,引用后端 commit hash + +### Phase 2 模板建议(基于 CONTEXT.md + Phase 1 风格) + +```markdown +### [2026-05-08] Phase 2(前端)RBAC 收敛 + AI 模型页入口 + +配套服务端 Phase:无(Phase 2 是纯前端 RBAC + UI 入口落地) +覆盖前端需求:CRED-FE-02、CRED-FE-03 + +- **文件路径**: + - `lib/permissions.ts`(修改) + - `app/ai-model/page.tsx`(修改) +- **修改类型**:修改(RBAC 矩阵扩展 + 页面入口控件 + 占位 Dialog) +- **修改内容**: + - `lib/permissions.ts`:`PermissionModule` union 末尾追加 `'credential-slot'` 字面量;`PERMISSION_MATRIX` 中"超级管理员"与"AI模型管理员"两个角色的数组末尾追加 `'credential-slot'`;其他 4 个角色(内容管理员/卡牌管理员/查看者/管理员)数组逐字保持不变;`getModuleFromPath` 完全不动(凭据槽位是 `/ai-model` 内嵌子能力,不占独立路由) + - `app/ai-model/page.tsx`:顶部加 `"use client"` 指令;新增 `useState` / `useEffect` import;新增 `KeyRound` lucide 图标 import;新增 `Dialog` 子组件 import;新增 `hasPermission` 工具 import;在 `DashboardHeader` children 中"添加新模型"按钮右侧加入受 `mounted && hasPermission('credential-slot')` 收敛的"凭据槽位" `