57 KiB
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 && <Button> 包裹保证未授权角色 DOM 中完全不存在。KeyRound 是新引入的 lucide 图标(全仓 grep 0 命中),lucide-react 0.454.0 已在 deps,无需 install。
Primary recommendation: Phase 2 唯一两个真实改动文件是 lib/permissions.ts(+1 union literal、+2 数组追加)和 app/ai-model/page.tsx(+几行 import、+useState hook、+{hasPermission(...) && <Button>} 嵌入 DashboardHeader 子节点、+尾部 <Dialog> 占位),加上 docs/修改记录.md 顶部追加一条 Phase 2 条目。预计单 plan 即可承载。
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
CRED-FE-02 RBAC 模块声明
PermissionModule类型扩充:在lib/permissions.ts找到PermissionModule类型定义,在 union 中添加'credential-slot'字面量PERMISSION_MATRIX矩阵:将'credential-slot'加入到「超级管理员」+「AI模型管理员」两个角色的模块列表(researcher 必须 read 完整矩阵给出每个角色当前包含哪些模块;这两个角色的列表末尾追加即可)- 其他 3 个角色(内容管理员 / 卡牌管理员 / 查看者)+ 1 个可能的「管理员」角色:不包含
credential-slot(保持原数组不变) getModuleFromPath('/ai-model')行为不变:/ai-model路径已映射到'ai-model'模块(researcher 确认),凭据槽位是/ai-model的子能力,不占独立路由 → 不要给getModuleFromPath加'/ai-model/credential-slot'之类映射
CRED-FE-03 /ai-model 页面入口
- 入口控件类型:Button(不是 Card)—— 最简、与现有页面风格一致;样式沿用 shadcn Button 组件(
components/ui/button.tsx),variant="outline" 或现有页面其他按钮的 variant - 位置:在
app/ai-model/page.tsx的 页面顶部 / 头部 / 工具栏区域(researcher 必须 read 现有页面结构给出具体插入点 —— 最可能是页面头部的标题旁边或现有"添加 AI 模型"之类按钮的同行右侧) - 图标:KeyRound(Lucide)—— 凭据语义最贴切;如果不可用降级到
Lock/Settings - 文案:按钮内文字 "凭据槽位"(中文)
- 可见性约束:用
hasPermission('credential-slot')包裹整个 Button JSX;不渲染时 DOM 中完全不存在({hasPermission('credential-slot') && <Button>...</Button>}) - 点击行为:本 phase 触发占位空对话框打开(基于
components/ui/dialog.tsx),对话框内容仅 DialogTitle + DialogDescription(中文文案"通用凭据槽位"+ 说明"对话框真实内容由 Phase 3 落地"),无表单 - 对话框组件位置:在
app/ai-model/page.tsx内联(不抽到独立文件 —— Phase 3 会把对话框抽到components/ai-model/CredentialSlotDialog.tsx) - 状态管理:用
useState<boolean>控制 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 后 </user_constraints>
<phase_requirements>
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 推荐 |
| </phase_requirements> |
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<boolean> [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:
# 已通过仓库内文件确认,无需联网
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="大模型管理"> │
│ [现有] <Button>添加新模型</Button> (pink-purple gradient) │
│ [新增] {hasPermission('credential-slot') && ( │
│ <Button variant="outline" onClick={openDialog}> │
│ <KeyRound /> 凭据槽位 │
│ </Button> │
│ )} │
│ </DashboardHeader> │
│ │
│ ... Tabs ... │
│ │
│ [新增] <Dialog open={isOpen} onOpenChange={setIsOpen}> │
│ <DialogContent> │
│ <DialogHeader> │
│ <DialogTitle>通用凭据槽位</DialogTitle> │
│ <DialogDescription> │
│ 对话框真实内容由 Phase 3 落地 │
│ </DialogDescription> │
│ </DialogHeader> │
│ </DialogContent> │
│ </Dialog> │
└──────────────────────────────────────────────────────────────────────┘
│
│ 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):
// 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;不要用 <DialogTrigger> 包按钮(会在 DOM 中嵌套)。
Example:
// 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") && (
<Button
variant="outline"
onClick={() => setIsCredentialDialogOpen(true)}
>
<KeyRound className="mr-2 h-4 w-4" />
凭据槽位
</Button>
)}
<Dialog
open={isCredentialDialogOpen}
onOpenChange={setIsCredentialDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>通用凭据槽位</DialogTitle>
<DialogDescription>
对话框真实内容由 Phase 3 落地
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</>
)
[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 一律降级到「查看者」,导致按钮总是不渲染、即使是超管也看不到。务必走useEffectmounted 守卫,复用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 <Dialog> + Radix data-state animation |
components/ui/dialog.tsx 已含 data-[state=open]:animate-in 等 Tailwind 动画工具类 [VERIFIED: dialog.tsx line 24-44] |
| 按钮样式 variant | 自己写 className 组合 | <Button variant="outline"> |
6 个 variant 已经定义:default / destructive / outline / secondary / ghost / link [VERIFIED: button.tsx line 11-21] |
| 图标 | 自己 inline svg | lucide-react <KeyRound /> |
240+ 图标,size/stroke 自动对齐;项目已 50+ 文件在用 [VERIFIED: 全仓 grep 命中] |
| 受控对话框状态 | 自己写 portal 管理 | useState<boolean> + Dialog open={x} onOpenChange={setX} |
Radix 已封装 portal、focus-trap、ESC 关闭、点遮罩关闭 |
Key insight: Phase 2 的所有需求都已在仓库现有 primitive 中可达;任何"自己写一个"都是反模式。唯一新增的是把 'credential-slot' 字面量串到 union/矩阵/页面三处。
Common Pitfalls
Pitfall 1:app/ai-model/page.tsx 当前是 Server Component
What goes wrong: 现有 app/ai-model/page.tsx(line 1-7)没有 "use client" 指令,是 Server Component。直接加 useState 会编译报错。
Why it happens: STACK.md + ARCHITECTURE.md 都指出大部分页面因需要 hooks 是 client,但 /ai-model 当前是纯展示静态卡片(mock 数据硬编码),所以一直是 server component。
How to avoid: 在文件 line 1 顶部加 "use client",并补 import { useState } from "react"。
Warning signs: useState is not a function / cannot use hooks in a server component 编译错误。
[VERIFIED: app/ai-model/page.tsx line 1-7 没有 "use client"]
Pitfall 2:水合不匹配(hydration mismatch)
What goes wrong: 加上 "use client" 后,hasPermission('credential-slot') 在 SSR 阶段(typeof window === "undefined")返回 false(默认「查看者」),但 hydration 后 localStorage 有「超级管理员」时返回 true,DOM 不一致。
Why it happens: lib/permissions.ts:65-67 的 getUserRole() 在 SSR 一律 fallback「查看者」。
How to avoid: 复用 components/sidebar.tsx:83-104 的 mounted 守卫模式:const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []),然后用 {mounted && hasPermission('credential-slot') && <Button>...</Button>} 包裹。
Warning signs: Next.js dev console 出现 Hydration failed warning,或者刷新页面瞬间按钮闪一下消失再出现。
Pitfall 3:把按钮放在 DashboardHeader 之外
What goes wrong: 现有 DashboardHeader 已经按 children 渲染右侧 actions(line 11-16 现有「添加新模型」按钮就放在 children 位置)。如果新按钮放在 <DashboardHeader> 后、<Tabs> 前,会形成游离的工具栏行,与现有视觉层级不一致。
How to avoid: 把"凭据槽位"按钮放到 DashboardHeader children 内,在「添加新模型」按钮右侧(同行)。
Warning signs: 视觉上按钮浮空、与页面顶部留白割裂。
Pitfall 4:Dialog 嵌在 DashboardHeader children 内
What goes wrong: 如果把 <Dialog> JSX 也放进 DashboardHeader children,会与现有 buttons 同级,引发 portal 边界问题(Radix Portal 会绕到 body 末尾,理论上不影响渲染但破坏组件树可读性)。
How to avoid: <Dialog> 作为 <DashboardShell> 的最后一个兄弟节点(在 </Tabs> 之后、</DashboardShell> 之前),与 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: 文件结构应是:
"use client" // line 1
import { useState } from "react" // line 2-x
import { ... } from "..."
// ...
Code Examples
lib/permissions.ts 改动 patch(提示式,非最终代码)
// 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<RoleName, PermissionModule[]> = {
超级管理员: [
"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<RoleName, PermissionModule[]> = {
超级管理员: [
"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 改动骨架
// 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 (
<DashboardShell>
<DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库">
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
<Plus className="mr-2 h-4 w-4" />
添加新模型
</Button>
{/* 凭据槽位入口(受 RBAC 收敛) */}
{mounted && hasPermission("credential-slot") && (
<Button
variant="outline"
onClick={() => setIsCredentialDialogOpen(true)}
>
<KeyRound className="mr-2 h-4 w-4" />
凭据槽位
</Button>
)}
</DashboardHeader>
<Tabs defaultValue="framework" className="space-y-4">
{/* ...原有 Tabs 内容完全保留... */}
</Tabs>
{/* 占位 Dialog(Phase 3 替换为真实表单) */}
<Dialog
open={isCredentialDialogOpen}
onOpenChange={setIsCredentialDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>通用凭据槽位</DialogTitle>
<DialogDescription>
对话框真实内容由 Phase 3 落地
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</DashboardShell>
)
}
[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 会自然横排;若不是,可包一层 <div className="flex gap-2"> 即可。Plan 阶段建议先 read dashboard-header.tsx 再下结论 |
Open Questions
-
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用<div className="flex items-center gap-2">包两个 Button 后再放进 children
- 已知:现有
-
加
"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
- 已知:现有 import 块(line 1-7)只 import
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 占位(<DialogTitle>通用凭据槽位</DialogTitle>) |
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 --noEmitexit 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)
export type PermissionModule =
| "dashboard"
| "users"
| "permissions"
| "ai-model"
| "outfits"
| "props"
| "home-decor"
| "food"
| "songs"
| "dances"
| "achievements"
| "affinity"
| "settings";
RoleName union(line 18)
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)
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)
export function getModuleFromPath(pathname: string): PermissionModule | null {
const segment = pathname.replace(/^\//, "").split("/")[0];
const pathMap: Record<string, PermissionModule> = {
"": "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-72getAllowedModules()line 77-80hasPathPermission(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)
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
<DashboardShell> // line 10
<DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库"> // line 11
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg"> // line 12
<Plus className="mr-2 h-4 w-4" /> // line 13
添加新模型 // line 14
</Button> // line 15
</DashboardHeader> // line 16
// line 17 (空行)
<Tabs defaultValue="framework" className="space-y-4"> // 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> 之前(即作为 DashboardHeader children 的第二个子节点,紧跟「添加新模型」按钮)
<DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库">
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 ...">
<Plus className="mr-2 h-4 w-4" />
添加新模型
</Button>
{/* ↓↓↓ 在此处插入 line 16 之前 ↓↓↓ */}
{mounted && hasPermission("credential-slot") && (
<Button
variant="outline"
onClick={() => setIsCredentialDialogOpen(true)}
>
<KeyRound className="mr-2 h-4 w-4" />
凭据槽位
</Button>
)}
{/* ↑↑↑ 插入结束 ↑↑↑ */}
</DashboardHeader>
推荐 variant: variant="outline" —— 与同页面所有"次要操作 / 详情 / 编辑"按钮(共 8+ 处)风格一致;不与"添加新模型"主 CTA 抢视觉权重,但点击区清晰。size 用默认(不传 size 即 default,与"添加新模型"同高 h-10)。
Dialog 占位插入点: line 442(</Tabs> 之后)、line 443(</DashboardShell> 之前)—— 作为 <DashboardShell> children 的最后一个兄弟节点:
</Tabs>
{/* ↓↓↓ 在此处插入 ↓↓↓ */}
<Dialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>通用凭据槽位</DialogTitle>
<DialogDescription>对话框真实内容由 Phase 3 落地</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
{/* ↑↑↑ 插入结束 ↑↑↑ */}
</DashboardShell>
现有 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 命中useStateinapp/ai-model/page.tsx)—— 当前是纯 server component。Phase 2 引入的两个 useState(mounted、isCredentialDialogOpen)是该文件首次 state 使用。 - 本文件没有任何
<Dialog>用法 —— Phase 2 新增的占位 Dialog 是该文件首次 Dialog 使用。
现状证据:components/ui/dialog.tsx(API 验证)
[VERIFIED: components/ui/dialog.tsx line 1-123, 全文已 read]
Export 列表(line 111-122)
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
确认 CONTEXT.md 提及的 5 个子组件 + 2 个补充组件均存在。
Controlled mode 支持验证
- line 9:
const Dialog = DialogPrimitive.Root——Dialog直通 RadixDialogPrimitive.Root - Radix
Dialog.Root的 controlled props:open?: boolean—— 当前是否打开onOpenChange?: (open: boolean) => void—— 状态变更回调(点 X、点遮罩、ESC 都会触发)defaultOpen?: boolean—— uncontrolled 默认值(本 phase 不用)
- 用法:
<Dialog open={isOpen} onOpenChange={setIsOpen}>完全支持[CITED: radix-ui.com/primitives/docs/components/dialog#root]
DialogContent props 自动 forward
DialogContent(line 32-54)通过 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> 透传所有 Radix props,并自带:
<DialogPortal>包装(line 36 + 52)<DialogOverlay>自动渲染(line 37)- 右上角 X 按钮
<DialogPrimitive.Close>(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)
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 合并到子元素(用于 <Button asChild><Link>...</Link></Button>)。本 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.jsonline 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)
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)
import { hasPathPermission, getUserRole } from "@/lib/permissions"
// ...
const [allowed, setAllowed] = useState<boolean | null>(null)
useEffect(() => {
setAllowed(hasPathPermission(pathname))
}, [pathname])
关键点: 用 useState<boolean | null>(null) 三态(null = 未挂载)+ useEffect 做客户端水合后判定,避免 SSR 阶段误判。
推荐复用模式(凭据槽位按钮)
// 模板:components/sidebar.tsx 风格(mounted 守卫)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// 渲染处:
{mounted && hasPermission("credential-slot") && (
<Button variant="outline" onClick={() => setIsCredentialDialogOpen(true)}>
<KeyRound className="mr-2 h-4 w-4" />
凭据槽位
</Button>
)}
现状证据:修改记录格式模板
[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 风格)
### [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')` 收敛的"凭据槽位" `<Button variant="outline">`,点击 setState 打开占位 Dialog;在 `<Tabs>` 后内联占位 `<Dialog open onOpenChange>`,含 `DialogTitle="通用凭据槽位"` + `DialogDescription="对话框真实内容由 Phase 3 落地"`,无表单
- **修改原因**:
- 落地 Milestone v1.0 Phase 2,让授权运营(超管 / AI模型管理员)能在 `/ai-model` 看到凭据槽位入口,未授权角色 DOM 中完全不渲染按钮(不是 disable)
- 沿用 `components/sidebar.tsx` 已有的 `mounted` 守卫 + `hasPermission` 模式,避免水合不匹配
- Dialog 占位(不实现表单)—— 表单逻辑由 Phase 3 / CRED-FE-04 落地,现先验证"按钮 + Dialog 联动"链路
- **跨项目联动**:无 — Phase 2 是纯前端 RBAC + UI 入口落地,不引入新跨项目契约;后端 commit 46d72b8 已建立的互引仍有效;Phase 3 引入实质 PUT 调用时若涉及新契约再评估
- **服务端联动**:同上「跨项目联动」字段
Sources
Primary(HIGH 置信,全部来自仓库内文件直接 read)
lib/permissions.tsline 1-123(完整文件已 read) — RBAC union + 矩阵 + 工具函数app/ai-model/page.tsxline 1-446(完整文件已 read) — 现有页面结构 + 按钮风格 + 插入点components/ui/dialog.tsxline 1-123(完整文件已 read) — Dialog API 与子组件 exportcomponents/ui/button.tsxline 1-57(完整文件已 read) — Button variant + size cvacomponents/sidebar.tsxline 1-193(完整文件已 read) —hasPermission+ mounted 守卫样板components/dashboard-shell.tsxline 1-49(完整文件已 read) — 路径级权限 useEffect 样板docs/修改记录.mdline 1-50(已 read) — 头部「修改格式说明」 + Phase 1 条目模板package.jsonline 50 — lucide-react ^0.454.0 版本确认.planning/REQUIREMENTS.md、.planning/ROADMAP.md、.planning/PROJECT.md— milestone 上下文 + success criteria.planning/phases/02-rbac-ai/02-CONTEXT.md— 用户决策(已逐字复制到<user_constraints>段)CLAUDE.md— 项目宪法(中文 / 修改记录 / 包管理器).planning/codebase/STRUCTURE.md、ARCHITECTURE.md— 代码库结构与架构
Secondary(MEDIUM 置信,已与官方源交叉)
- lucide.dev
KeyRound图标存在性 — Lucide 官网图标列表(图标自 0.298+ 即存在)
Tertiary(LOW 置信)
- 无 —— 本 phase 不依赖任何外部库的未读文档;所有 API 都在仓库内 shadcn 复制粘贴源码中已验证。
Metadata
Confidence breakdown:
- Standard stack: HIGH — 所有依赖已 read 源码或 grep package.json 确认
- Architecture: HIGH — 现有
components/sidebar.tsxmounted 模式已有 production 验证 - Pitfalls: HIGH — Pitfall 1("use client" 缺失)已通过 read line 1-7 直接确认;Pitfall 2(hydration)由 sidebar.tsx 已有 mounted 守卫反推;Pitfall 3(按钮位置)由 line 11-16 现有结构推导;Pitfall 4 / 5 是 Next.js 通用知识 [CITED: nextjs.org/docs/app/api-reference/directives/use-client]
Research date: 2026-05-08 Valid until: 2026-06-07(30 天 —— 仓库内文件,依赖稳定;如 Phase 3 启动前 lib/permissions.ts 或 app/ai-model/page.tsx 有手工改动,需要重读)