1038 lines
57 KiB
Markdown
1038 lines
57 KiB
Markdown
# 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 有手工改动,需要重读)
|