1038 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 2RBAC 收敛 + 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 加 tooltipKeyRound 图标语义不够明显时)—— 推荐加,让运营秒懂
### 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` | localshadcn 复制粘贴) | Button + variant | 入口按钮样式 |
| `@/components/ui/dialog` | localshadcn 复制粘贴) | 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已存在
┌──────────────────────────────────────────────────────────────────────┐
│ DashboardShellcomponents/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`**
```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 2shadcn 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**
```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") && (
<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 一律降级到「查看者」,导致按钮总是不渲染、即使是超管也看不到。务必走 `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 `<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 有「超级管理员」时返回 trueDOM 不一致。
**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 渲染右侧 actionsline 11-16 现有「添加新模型」按钮就放在 children 位置)。如果新按钮放在 `<DashboardHeader>` 后、`<Tabs>` 前,会形成游离的工具栏行,与现有视觉层级不一致。
**How to avoid** 把"凭据槽位"按钮放到 `DashboardHeader` children 内,在「添加新模型」按钮**右侧**(同行)。
**Warning signs** 视觉上按钮浮空、与页面顶部留白割裂。
### Pitfall 4Dialog 嵌在 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** 文件结构应是:
```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<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` 改动骨架
```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>
{/* 占位 DialogPhase 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 包 Buttonuncontrolled | 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
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``<div className="flex items-center gap-2">` 包两个 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`(期望 ≥3union 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按钮文案+ ≥1DialogTitle | ✅ app/ai-model/page.tsx 已存在 |
| CRED-FE-03 | 页面含 `KeyRound` import 与使用 | static-grep | `grep -n "KeyRound" app/ai-model/page.tsx` ≥2import + 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 --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 | noPhase 2 无表单输入占位 Dialog 不收集任何输入 | |
| V6 Cryptography | no | |
### Known Threat Patterns for Next.js 15 + 客户端 RBAC
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| 仅前端校验导致越权修改 localStorage.user_role 即可绕过 | Elevation of Privilege | **后端独立校验**PERM-06CONCERNS.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` unionline 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<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-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
<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-234line 339-341line 436-438 | 主要 CTA |
| Outline | `variant="outline"` + 各种 hover | line 74-80"查看详情")、line 96-103"设为主要")、line 184-192"编辑人格设定")、line 285-292"试听示例")、line 322-330line 384-389line 421-427line 432-434 | 次要操作详情编辑入口 |
### "凭据槽位"按钮**最佳插入点**
**插入位置:** `app/ai-model/page.tsx` **line 15 之后、line 16 `</DashboardHeader>` 之前**即作为 `DashboardHeader` children 的第二个子节点紧跟添加新模型按钮
```tsx
<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 的最后一个兄弟节点
```tsx
</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 命中 `useState` in `app/ai-model/page.tsx`)—— 当前是纯 server componentPhase 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
```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 不用
- 用法`<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 warningCONTEXT.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 + sizeline 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 合并到子元素用于 `<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-reactconfirmed 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<boolean | null>(null)
useEffect(() => {
setAllowed(hasPathPermission(pathname))
}, [pathname])
```
**关键点:** `useState<boolean | null>(null)` 三态null = 未挂载)+ `useEffect` 做客户端水合后判定避免 SSR 阶段误判
### 推荐复用模式(凭据槽位按钮)
```tsx
// 模板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 风格)
```markdown
### [2026-05-08] Phase 2前端RBAC 收敛 + AI 模型页入口
配套服务端 PhasePhase 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
### PrimaryHIGH 置信,全部来自仓库内文件直接 read
- `lib/permissions.ts` line 1-123完整文件已 read RBAC union + 矩阵 + 工具函数
- `app/ai-model/page.tsx` line 1-446完整文件已 read 现有页面结构 + 按钮风格 + 插入点
- `components/ui/dialog.tsx` line 1-123完整文件已 read Dialog API 与子组件 export
- `components/ui/button.tsx` line 1-57完整文件已 read Button variant + size cva
- `components/sidebar.tsx` line 1-193完整文件已 read `hasPermission` + mounted 守卫样板
- `components/dashboard-shell.tsx` line 1-49完整文件已 read 路径级权限 useEffect 样板
- `docs/修改记录.md` line 1-50 read 头部修改格式说明 + Phase 1 条目模板
- `package.json` line 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` 代码库结构与架构
### SecondaryMEDIUM 置信,已与官方源交叉)
- lucide.dev `KeyRound` 图标存在性 Lucide 官网图标列表图标自 0.298+ 即存在
### TertiaryLOW 置信)
- —— phase 不依赖任何外部库的未读文档所有 API 都在仓库内 shadcn 复制粘贴源码中已验证
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH 所有依赖已 read 源码或 grep package.json 确认
- Architecture: HIGH 现有 `components/sidebar.tsx` mounted 模式已有 production 验证
- Pitfalls: HIGH Pitfall 1"use client" 缺失已通过 read line 1-7 直接确认Pitfall 2hydration 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-0730 —— 仓库内文件依赖稳定 Phase 3 启动前 lib/permissions.ts app/ai-model/page.tsx 有手工改动需要重读