# 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:**

```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="大模型管理">                                │
│    [现有] <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 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：**

```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 有「超级管理员」时返回 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：** 文件结构应是：

```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>

      {/* 占位 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

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`（期望 ≥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 --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<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-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 的第二个子节点，紧跟「添加新模型」按钮）

```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 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）

```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 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 合并到子元素（用于 `<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.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 模型页入口

配套服务端 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.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` — 代码库结构与架构

### 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.tsx` mounted 模式已有 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 有手工改动，需要重读）
