668 lines
31 KiB
Markdown
668 lines
31 KiB
Markdown
---
|
||
phase: 02-rbac-ai
|
||
plan: 01
|
||
type: execute
|
||
wave: 1
|
||
depends_on: []
|
||
files_modified:
|
||
- lib/permissions.ts
|
||
- app/ai-model/page.tsx
|
||
autonomous: true
|
||
requirements:
|
||
- CRED-FE-02
|
||
- CRED-FE-03
|
||
must_haves:
|
||
truths:
|
||
- "PermissionModule union 含 'credential-slot' 字面量(第 14 项,紧随 'settings' 之后)"
|
||
- "PERMISSION_MATRIX[\"超级管理员\"] 数组末尾含 'credential-slot'"
|
||
- "PERMISSION_MATRIX[\"AI模型管理员\"] 数组末尾含 'credential-slot'"
|
||
- "PERMISSION_MATRIX[\"内容管理员\"] / [\"卡牌管理员\"] / [\"查看者\"] / [\"管理员\"] 四个数组逐字不变(不含 'credential-slot')"
|
||
- "getModuleFromPath('/ai-model') 行为不变(pathMap 中 'ai-model': 'ai-model' 仍存在且无新增 credential-slot 路径映射)"
|
||
- "app/ai-model/page.tsx 第 1 行为 'use client',文件已转为 Client Component"
|
||
- "app/ai-model/page.tsx 在 DashboardHeader 内含受 hasPermission('credential-slot') 收敛的「凭据槽位」Button(KeyRound 图标,variant='outline')"
|
||
- "未授权角色(查看者 / 内容管理员等)登录访问 /ai-model 时,凭据槽位 Button 在 DOM 中完全不存在(不仅是隐藏)"
|
||
- "占位 Dialog 在 </Tabs> 之后、</DashboardShell> 之前,controlled mode(open + onOpenChange),DialogTitle 含中文「通用凭据槽位」+ DialogDescription 含「对话框真实内容由 Phase 3 落地」"
|
||
- "useState<boolean>(false) 控制 isCredentialDialogOpen;点击 Button → setIsCredentialDialogOpen(true) 打开 Dialog"
|
||
- "为避免 SSR 水合不匹配,hasPermission 调用走 mounted 守卫(复用 components/sidebar.tsx:83-104 模式)"
|
||
artifacts:
|
||
- path: "lib/permissions.ts"
|
||
provides: "PermissionModule 14 项 union + 6 角色矩阵(其中 2 角色含 credential-slot)"
|
||
contains: "'credential-slot'"
|
||
min_lines: 120
|
||
- path: "app/ai-model/page.tsx"
|
||
provides: "Client Component,含凭据槽位入口 Button + 占位 Dialog + useState/useEffect/hasPermission/KeyRound 引用"
|
||
contains: "use client"
|
||
min_lines: 460
|
||
key_links:
|
||
- from: "app/ai-model/page.tsx"
|
||
to: "lib/permissions.ts:hasPermission"
|
||
via: "named import + 调用 hasPermission('credential-slot')"
|
||
pattern: "hasPermission\\([\"']credential-slot[\"']\\)"
|
||
- from: "app/ai-model/page.tsx Button onClick"
|
||
to: "Dialog open prop"
|
||
via: "useState<boolean> setIsCredentialDialogOpen"
|
||
pattern: "setIsCredentialDialogOpen\\(true\\)"
|
||
- from: "lib/permissions.ts PermissionModule union"
|
||
to: "PERMISSION_MATRIX 角色数组"
|
||
via: "TS literal 校验 + Record<RoleName, PermissionModule[]>"
|
||
pattern: "[\"']credential-slot[\"']"
|
||
---
|
||
|
||
<objective>
|
||
落地 Phase 2 的两条核心需求:
|
||
1. **CRED-FE-02**:扩展 `lib/permissions.ts` 的 RBAC,让 `'credential-slot'` 模块仅对「超级管理员」与「AI模型管理员」开放
|
||
2. **CRED-FE-03**:在 `/ai-model` 页面渲染受 `hasPermission('credential-slot')` 收敛的「凭据槽位」入口 Button,点击触发占位 Dialog 打开
|
||
|
||
Purpose:让授权运营立即能在大模型管理页看到入口控件,未授权角色彻底看不到(DOM 中完全不存在);为 Phase 3 的真实表单落地预留 Dialog 挂载点。
|
||
Output:`lib/permissions.ts`(修改)+ `app/ai-model/page.tsx`(修改)。无新依赖、不动 lockfile。
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@.planning/REQUIREMENTS.md
|
||
@.planning/phases/02-rbac-ai/02-CONTEXT.md
|
||
@.planning/phases/02-rbac-ai/02-RESEARCH.md
|
||
@CLAUDE.md
|
||
@lib/permissions.ts
|
||
@app/ai-model/page.tsx
|
||
@components/dashboard-header.tsx
|
||
@components/ui/button.tsx
|
||
@components/ui/dialog.tsx
|
||
@components/sidebar.tsx
|
||
</context>
|
||
|
||
<interfaces>
|
||
<!-- 执行器无需再去翻代码:以下都是已读完整文件后摘出的关键 contract -->
|
||
|
||
### `lib/permissions.ts` 当前结构(VERIFIED 全文 123 行)
|
||
|
||
```ts
|
||
// line 17(保持不变)
|
||
export type RoleName = "超级管理员" | "内容管理员" | "AI模型管理员" | "卡牌管理员" | "查看者" | "管理员";
|
||
|
||
// line 21-34(union 当前 13 项,待 +1)
|
||
export type PermissionModule =
|
||
| "dashboard"
|
||
| "users"
|
||
| "permissions"
|
||
| "ai-model"
|
||
| "outfits"
|
||
| "props"
|
||
| "home-decor"
|
||
| "food"
|
||
| "songs"
|
||
| "dances"
|
||
| "achievements"
|
||
| "affinity"
|
||
| "settings";
|
||
|
||
// line 37-60(矩阵当前 6 角色)
|
||
const PERMISSION_MATRIX: Record<RoleName, PermissionModule[]> = {
|
||
超级管理员: [
|
||
"dashboard", "users", "permissions", "ai-model",
|
||
"outfits", "props", "home-decor", "food",
|
||
"songs", "dances", "achievements", "affinity", "settings",
|
||
],
|
||
内容管理员: [
|
||
"dashboard", "outfits", "props", "home-decor", "food",
|
||
"songs", "dances", "achievements", "affinity",
|
||
],
|
||
AI模型管理员: [
|
||
"dashboard", "ai-model",
|
||
],
|
||
卡牌管理员: [
|
||
"dashboard", "outfits", "props", "home-decor", "food",
|
||
],
|
||
查看者: [
|
||
"dashboard",
|
||
],
|
||
管理员: [
|
||
"dashboard",
|
||
],
|
||
};
|
||
|
||
// line 92-113(getModuleFromPath,本 phase 完全不动)
|
||
export function getModuleFromPath(pathname: string): PermissionModule | null {
|
||
const segment = pathname.replace(/^\//, "").split("/")[0];
|
||
const pathMap: Record<string, PermissionModule> = {
|
||
"": "dashboard",
|
||
"ai-model": "ai-model",
|
||
// ... 其余 11 条
|
||
};
|
||
return pathMap[segment] ?? null;
|
||
}
|
||
|
||
// line 85-87(hasPermission,签名稳定,仅验证 'credential-slot' 字面量在 union 中合法)
|
||
export function hasPermission(module: PermissionModule): boolean {
|
||
return getAllowedModules().includes(module);
|
||
}
|
||
```
|
||
|
||
### `app/ai-model/page.tsx` 当前结构(VERIFIED 全文 446 行)
|
||
|
||
```tsx
|
||
// line 1-7(当前 import;缺 "use client",是 Server Component)
|
||
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 { Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User } from "lucide-react"
|
||
|
||
// line 8-16(DashboardHeader 区域,含现有「添加新模型」按钮)
|
||
export default function AIModelPage() {
|
||
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>
|
||
</DashboardHeader>
|
||
|
||
<Tabs defaultValue="framework" className="space-y-4">
|
||
...
|
||
</Tabs>
|
||
</DashboardShell> // line 443
|
||
)
|
||
}
|
||
```
|
||
|
||
### `components/dashboard-header.tsx`(VERIFIED 全文 20 行)
|
||
|
||
```tsx
|
||
// line 8-19:children 容器是 flex row(items-center justify-between)
|
||
export function DashboardHeader({ heading, text, children }: DashboardHeaderProps) {
|
||
return (
|
||
<div className="flex items-center justify-between px-2 mb-8">
|
||
<div className="grid gap-1">
|
||
<h1 className="...">{heading}</h1>
|
||
{text && <div className="text-lg text-muted-foreground">{text}</div>}
|
||
</div>
|
||
{children} {/* ← 直接渲染 children,本身不是 flex 容器 */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
**关键判断**:外层是 `flex items-center justify-between`,左侧是 heading 容器、右侧 children 直接渲染。当 children 是**多个** Button 时,多个 Button 会被同等参与 flex 横排,但**之间无 gap**。**结论**:在 page.tsx 把两个 Button 用 `<div className="flex items-center gap-2">` 包起来,作为 children 单一节点传入,避免视觉粘连。
|
||
|
||
### `components/ui/dialog.tsx` 关键导出(VERIFIED line 111-122)
|
||
|
||
```ts
|
||
export {
|
||
Dialog, // = DialogPrimitive.Root(直通,支持 controlled props)
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
// ... DialogFooter / Portal / Overlay / Close / Trigger 本 phase 不用
|
||
}
|
||
```
|
||
|
||
**Controlled mode 用法**:`<Dialog open={state} onOpenChange={setState}>`,内部用 `<DialogContent>` 包 `<DialogHeader>{<DialogTitle/> + <DialogDescription/>}</DialogHeader>`,本 phase 不放 footer / form。
|
||
|
||
### `components/ui/button.tsx` 关键 variants(VERIFIED line 11-21)
|
||
|
||
variant 取值:`default | destructive | outline | secondary | ghost | link`
|
||
size 取值:`default | sm | lg | icon`
|
||
|
||
本 phase 入口 Button:`variant="outline"`、`size` 默认(与 CONTEXT.md 锁定一致;与现有页面其他「查看详情」「试听示例」等次要按钮的 variant="outline" 视觉一致)。
|
||
|
||
### `components/sidebar.tsx` mounted 守卫模式(VERIFIED line 83-104)
|
||
|
||
```tsx
|
||
"use client"
|
||
import { useState, useEffect } from "react"
|
||
import { hasPermission } from "@/lib/permissions"
|
||
|
||
const [mounted, setMounted] = useState(false)
|
||
useEffect(() => { setMounted(true) }, [])
|
||
|
||
// 然后用 {mounted && hasPermission(...) && <X/>} 包裹任何依赖 localStorage 的 UI
|
||
```
|
||
|
||
**为什么必须 mounted 守卫**:`getUserRole()` 在 SSR 阶段(typeof window === "undefined")一律 fallback「查看者」;hydration 后才能读到真实 role。不加守卫会出现「超管刷新页面瞬间按钮闪一下消失再出现」的水合警告。
|
||
</interfaces>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>任务 1:扩展 lib/permissions.ts RBAC(PermissionModule union +1 / 矩阵 +2 角色)</name>
|
||
<files>lib/permissions.ts</files>
|
||
|
||
<read_first>
|
||
1. **必读**:`lib/permissions.ts` 完整 1-123 行(确认 union 当前 13 项 + 6 角色数组当前内容)
|
||
2. **必读**:`.planning/phases/02-rbac-ai/02-CONTEXT.md` 的「Locked Decisions / CRED-FE-02 RBAC 模块声明」段(4 条 bullet)
|
||
3. **必读**:`.planning/phases/02-rbac-ai/02-RESEARCH.md` 「`lib/permissions.ts` 改动 patch」段(line 358-435 完整 before/after diff)
|
||
</read_first>
|
||
|
||
<action>
|
||
用 Edit 工具对 `lib/permissions.ts` 做**4 处** old_string → new_string 替换;其他内容**逐字不动**。
|
||
|
||
**改动 1:`PermissionModule` union(line 21-34)追加 `'credential-slot'`**
|
||
|
||
old_string(完全照搬 line 21-34,含分号):
|
||
```ts
|
||
export type PermissionModule =
|
||
| "dashboard"
|
||
| "users"
|
||
| "permissions"
|
||
| "ai-model"
|
||
| "outfits"
|
||
| "props"
|
||
| "home-decor"
|
||
| "food"
|
||
| "songs"
|
||
| "dances"
|
||
| "achievements"
|
||
| "affinity"
|
||
| "settings";
|
||
```
|
||
|
||
new_string:
|
||
```ts
|
||
export type PermissionModule =
|
||
| "dashboard"
|
||
| "users"
|
||
| "permissions"
|
||
| "ai-model"
|
||
| "outfits"
|
||
| "props"
|
||
| "home-decor"
|
||
| "food"
|
||
| "songs"
|
||
| "dances"
|
||
| "achievements"
|
||
| "affinity"
|
||
| "settings"
|
||
| "credential-slot";
|
||
```
|
||
|
||
**改动 2:「超级管理员」数组末尾追加 `"credential-slot"`(line 38-42)**
|
||
|
||
old_string:
|
||
```ts
|
||
超级管理员: [
|
||
"dashboard", "users", "permissions", "ai-model",
|
||
"outfits", "props", "home-decor", "food",
|
||
"songs", "dances", "achievements", "affinity", "settings",
|
||
],
|
||
```
|
||
|
||
new_string:
|
||
```ts
|
||
超级管理员: [
|
||
"dashboard", "users", "permissions", "ai-model",
|
||
"outfits", "props", "home-decor", "food",
|
||
"songs", "dances", "achievements", "affinity", "settings",
|
||
"credential-slot",
|
||
],
|
||
```
|
||
|
||
**改动 3:「AI模型管理员」数组末尾追加 `"credential-slot"`(line 47-49)**
|
||
|
||
old_string:
|
||
```ts
|
||
AI模型管理员: [
|
||
"dashboard", "ai-model",
|
||
],
|
||
```
|
||
|
||
new_string:
|
||
```ts
|
||
AI模型管理员: [
|
||
"dashboard", "ai-model",
|
||
"credential-slot",
|
||
],
|
||
```
|
||
|
||
**改动 4(可选 / 推荐):更新文件顶部「权限矩阵对照表」注释(line 1-15),新增一行让文档与代码同步**
|
||
|
||
old_string(完全照搬 line 4-14):
|
||
```ts
|
||
* 权限矩阵对照表:
|
||
* | 模块 | 超级管理员 | 内容管理员 | AI模型管理员 | 卡牌管理员 | 查看者 |
|
||
* |-------------|-----------|-----------|------------|-----------|-------|
|
||
* | 仪表盘查看 | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||
* | 用户管理 | ✓ | | | | |
|
||
* | 角色权限管理 | ✓ | | | | |
|
||
* | AI模型管理 | ✓ | | ✓ | | |
|
||
* | 服装管理 | ✓ | ✓ | | ✓ | |
|
||
* | 道具管理 | ✓ | ✓ | | ✓ | |
|
||
* | 歌曲管理 | ✓ | ✓ | | | |
|
||
* | 系统设置 | ✓ | | | | |
|
||
```
|
||
|
||
new_string:
|
||
```ts
|
||
* 权限矩阵对照表:
|
||
* | 模块 | 超级管理员 | 内容管理员 | AI模型管理员 | 卡牌管理员 | 查看者 |
|
||
* |-------------|-----------|-----------|------------|-----------|-------|
|
||
* | 仪表盘查看 | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||
* | 用户管理 | ✓ | | | | |
|
||
* | 角色权限管理 | ✓ | | | | |
|
||
* | AI模型管理 | ✓ | | ✓ | | |
|
||
* | 凭据槽位 | ✓ | | ✓ | | |
|
||
* | 服装管理 | ✓ | ✓ | | ✓ | |
|
||
* | 道具管理 | ✓ | ✓ | | ✓ | |
|
||
* | 歌曲管理 | ✓ | ✓ | | | |
|
||
* | 系统设置 | ✓ | | | | |
|
||
```
|
||
|
||
**明确不要做的事**:
|
||
- 不要动「内容管理员」「卡牌管理员」「查看者」「管理员」4 个角色的数组(逐字不变)
|
||
- 不要动 `getModuleFromPath` 的 `pathMap`(不要新增 `'credential-slot'` 路径映射,凭据槽位是 `/ai-model` 子能力不占独立路由)
|
||
- 不要动 `getUserRole` / `getAllowedModules` / `hasPermission` / `hasPathPermission` 四个函数体
|
||
- 不要新增 import / export
|
||
- 不要重排数组顺序(仅在数组**末尾**追加)
|
||
</action>
|
||
|
||
<acceptance_criteria>
|
||
- `grep -nE "['\"]credential-slot['\"]" lib/permissions.ts` 命中 **3 行**(union literal + 「超级管理员」数组 + 「AI模型管理员」数组)
|
||
- `grep -n "credential-slot" lib/permissions.ts` 命中**总共 4 行**(含 1 行注释表新增的「凭据槽位」行 = 4;若改动 4 跳过则为 3)
|
||
- `grep -n "getModuleFromPath" lib/permissions.ts` 仍命中函数定义(行号可能不变),且 `grep -n '"ai-model": "ai-model"' lib/permissions.ts` 仍命中 1 行
|
||
- `grep -nE "(内容管理员|卡牌管理员|查看者|管理员):" lib/permissions.ts` 各角色后的数组**不**含 `credential-slot`(人工核对 4 个数组逐字与原文一致)
|
||
- 文件总行数变化:union +1 行、超管数组 +1 行、AI模型管理员数组 +1 行,注释表 +1 行 = 总 +4 行(123 → 127;若跳过改动 4 则 123 → 126)
|
||
</acceptance_criteria>
|
||
|
||
<verify>
|
||
<automated>
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin && npx tsc --noEmit 2>&1 | grep -E "lib/permissions\.ts" | wc -l
|
||
# 预期:0(lib/permissions.ts 在改动后零类型错误;存量错误数与 Phase 1 的 67 条一致或更少;不能引入新的指向 lib/permissions.ts 的错误)
|
||
</automated>
|
||
<automated>
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin && grep -cE "['\"]credential-slot['\"]" lib/permissions.ts
|
||
# 预期:3
|
||
</automated>
|
||
</verify>
|
||
|
||
<done>
|
||
- `lib/permissions.ts` PermissionModule union 含 14 项(最后一项是 `"credential-slot"`)
|
||
- 「超级管理员」数组末尾含 `"credential-slot"`,「AI模型管理员」数组末尾含 `"credential-slot"`
|
||
- 其他 4 角色数组逐字不变
|
||
- `getModuleFromPath` 函数体完全不变
|
||
- `npx tsc --noEmit` 不引入指向 `lib/permissions.ts` 的新错误
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>任务 2:app/ai-model/page.tsx 加 "use client"、入口 Button、占位 Dialog</name>
|
||
<files>app/ai-model/page.tsx</files>
|
||
|
||
<read_first>
|
||
1. **必读**:`app/ai-model/page.tsx` 完整 1-446 行(确认 line 1-7 import 块、line 8-16 DashboardHeader 段、line 442 `</Tabs>`、line 443 `</DashboardShell>`)
|
||
2. **必读**:`components/sidebar.tsx` line 83-104(mounted 守卫模式样板,复用其结构)
|
||
3. **必读**:`.planning/phases/02-rbac-ai/02-CONTEXT.md` 的「CRED-FE-03 /ai-model 页面入口」段(8 条 bullet)+ 「Claude's Discretion」段
|
||
4. **必读**:`.planning/phases/02-rbac-ai/02-RESEARCH.md` 的「Code Examples / `app/ai-model/page.tsx` 改动骨架」段(line 439-512)+ 「Common Pitfalls」5 条
|
||
5. **依赖任务 1 完成**:`lib/permissions.ts` 的 PermissionModule union 必须已含 `'credential-slot'`,否则本任务的 `hasPermission('credential-slot')` 调用会被 TS 报错
|
||
</read_first>
|
||
|
||
<action>
|
||
对 `app/ai-model/page.tsx` 做 **5 处**精确改动;其他内容(Tabs / Card / 现有 Button)**逐字不动**。
|
||
|
||
**改动 1:line 1 顶部新增 `"use client"` 指令(必须在所有 import 之前)**
|
||
|
||
old_string(line 1-2,完整照搬现状):
|
||
```tsx
|
||
import { DashboardShell } from "@/components/dashboard-shell"
|
||
import { DashboardHeader } from "@/components/dashboard-header"
|
||
```
|
||
|
||
new_string:
|
||
```tsx
|
||
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { DashboardShell } from "@/components/dashboard-shell"
|
||
import { DashboardHeader } from "@/components/dashboard-header"
|
||
```
|
||
|
||
**改动 2:扩展 lucide-react import(line 6 现状)+ 新增 Dialog import + hasPermission import**
|
||
|
||
old_string:
|
||
```tsx
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
import { Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User } from "lucide-react"
|
||
```
|
||
|
||
new_string:
|
||
```tsx
|
||
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"
|
||
```
|
||
|
||
**改动 3:函数体顶部加 useState / useEffect(mounted 守卫 + Dialog 开关)**
|
||
|
||
old_string(line 8-11 完整照搬):
|
||
```tsx
|
||
export default function AIModelPage() {
|
||
return (
|
||
<DashboardShell>
|
||
<DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库">
|
||
```
|
||
|
||
new_string:
|
||
```tsx
|
||
export default function AIModelPage() {
|
||
const [mounted, setMounted] = useState(false)
|
||
const [isCredentialDialogOpen, setIsCredentialDialogOpen] = useState(false)
|
||
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
}, [])
|
||
|
||
return (
|
||
<DashboardShell>
|
||
<DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库">
|
||
```
|
||
|
||
**改动 4:在 DashboardHeader 内部、line 16 `</DashboardHeader>` 之前用 flex 容器把现有「添加新模型」Button 与新增的「凭据槽位」Button 包起来(dashboard-header 的 children 是单 slot,多 Button 需自行加 gap)**
|
||
|
||
old_string(line 12-16 完整照搬):
|
||
```tsx
|
||
<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>
|
||
</DashboardHeader>
|
||
```
|
||
|
||
new_string:
|
||
```tsx
|
||
<DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库">
|
||
<div className="flex items-center gap-2">
|
||
<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>
|
||
{mounted && hasPermission("credential-slot") && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setIsCredentialDialogOpen(true)}
|
||
>
|
||
<KeyRound className="mr-2 h-4 w-4" />
|
||
凭据槽位
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</DashboardHeader>
|
||
```
|
||
|
||
**改动 5:在 `</Tabs>`(line 442)之后、`</DashboardShell>`(line 443)之前插入占位 Dialog**
|
||
|
||
old_string(line 442-444 完整照搬,含末尾 `}`):
|
||
```tsx
|
||
</Tabs>
|
||
</DashboardShell>
|
||
)
|
||
```
|
||
|
||
new_string:
|
||
```tsx
|
||
</Tabs>
|
||
|
||
<Dialog
|
||
open={isCredentialDialogOpen}
|
||
onOpenChange={setIsCredentialDialogOpen}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>通用凭据槽位</DialogTitle>
|
||
<DialogDescription>
|
||
对话框真实内容由 Phase 3 落地
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</DashboardShell>
|
||
)
|
||
```
|
||
|
||
**明确不要做的事**:
|
||
- 不要新建 `components/ai-model/CredentialSlotDialog.tsx`(Phase 3 才抽离)
|
||
- 不要给 Dialog 加表单 / 输入框 / 按钮 / 提交逻辑(Phase 3 落地)
|
||
- 不要 import sonner / useToast(Phase 3 落地)
|
||
- 不要 import `getCredentialSlot` / `updateCredentialSlot`(Phase 3 落地)
|
||
- 不要动 Tabs / TabsContent / Card 任何子内容(line 18-441 全部保留逐字不变)
|
||
- 不要给 Button 加 `disabled` / loading state
|
||
- 不要把「添加新模型」Button 的 className 风格删掉或修改
|
||
- 如果 `KeyRound` 在 lucide-react 中报错(编译时 import 失败),降级到 `Lock`(同包内图标),但**必须先尝试 KeyRound**(lucide-react ^0.454.0 已锁,KeyRound 自 0.298+ 即存在)
|
||
</action>
|
||
|
||
<acceptance_criteria>
|
||
- `head -n 1 app/ai-model/page.tsx` 输出 `"use client"`
|
||
- `grep -n "useState" app/ai-model/page.tsx` 命中 import 行 + 至少 2 处调用(mounted + isCredentialDialogOpen)
|
||
- `grep -n "useEffect" app/ai-model/page.tsx` 命中 import 行 + 至少 1 处调用
|
||
- `grep -n "KeyRound" app/ai-model/page.tsx` 命中 ≥2(import + JSX)
|
||
- `grep -nE 'hasPermission\(["'\'']credential-slot' app/ai-model/page.tsx` 命中 ≥1
|
||
- `grep -n "凭据槽位" app/ai-model/page.tsx` 命中 ≥2(Button 文案 + DialogTitle)
|
||
- `grep -n "通用凭据槽位" app/ai-model/page.tsx` 命中 ≥1(DialogTitle)
|
||
- `grep -n "对话框真实内容由 Phase 3 落地" app/ai-model/page.tsx` 命中 1
|
||
- `grep -n "setIsCredentialDialogOpen(true)" app/ai-model/page.tsx` 命中 1
|
||
- `grep -n "isCredentialDialogOpen" app/ai-model/page.tsx` 命中 ≥3(useState + onClick + Dialog open prop)
|
||
- `grep -nE 'variant=["'\'']outline["'\'']' app/ai-model/page.tsx` 命中 ≥1(凭据槽位 Button;可能有更多匹配如其他卡片内的 outline 按钮,无所谓)
|
||
- `grep -n "from \"@/components/ui/dialog\"" app/ai-model/page.tsx` 命中 1
|
||
- `grep -n "from \"@/lib/permissions\"" app/ai-model/page.tsx` 命中 1
|
||
- `grep -nE "添加新模型" app/ai-model/page.tsx` 仍命中 ≥2(保留原有按钮文案;DashboardHeader 内 + CardFooter 内)
|
||
</acceptance_criteria>
|
||
|
||
<verify>
|
||
<automated>
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin && npx tsc --noEmit 2>&1 | grep -E "app/ai-model/page\.tsx" | wc -l
|
||
# 预期:0(新文件零类型错误;沿用 Phase 1 已建立的判定模式:tsc 整体退出码 2,但 grep 过滤后不指向 app/ai-model/page.tsx)
|
||
</automated>
|
||
<automated>
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin && head -n 1 app/ai-model/page.tsx
|
||
# 预期:包含 "use client"(精确字符串:"use client")
|
||
</automated>
|
||
<automated>
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin && grep -cE "(KeyRound|凭据槽位|通用凭据槽位|hasPermission\\([\"']credential-slot|setIsCredentialDialogOpen|对话框真实内容由 Phase 3 落地)" app/ai-model/page.tsx
|
||
# 预期:≥10(KeyRound×2 + 凭据槽位×2 + 通用凭据槽位×1 + hasPermission(credential-slot)×1 + setIsCredentialDialogOpen×3 + 对话框真实内容由 Phase 3 落地×1)
|
||
</automated>
|
||
</verify>
|
||
|
||
<done>
|
||
- 文件 line 1 是 `"use client"`
|
||
- 新增 5 个 import:`useState`、`useEffect`(react)+ Dialog 子组件 5 个(@/components/ui/dialog)+ `KeyRound`(lucide-react)+ `hasPermission`(@/lib/permissions)
|
||
- 函数体顶部含 `mounted` + `isCredentialDialogOpen` 两个 useState + 1 个 useEffect 设 mounted
|
||
- DashboardHeader children 改为 `<div className="flex items-center gap-2">` 包两个 Button:保留原「添加新模型」Button + 新增 `{mounted && hasPermission("credential-slot") && <Button variant="outline" onClick={() => setIsCredentialDialogOpen(true)}><KeyRound .../>凭据槽位</Button>}`
|
||
- `</Tabs>` 之后、`</DashboardShell>` 之前插入 controlled mode `<Dialog open={isCredentialDialogOpen} onOpenChange={setIsCredentialDialogOpen}>` + `<DialogContent><DialogHeader><DialogTitle>通用凭据槽位</DialogTitle><DialogDescription>对话框真实内容由 Phase 3 落地</DialogDescription></DialogHeader></DialogContent></Dialog>`
|
||
- Tabs / TabsContent / Card 等所有原有内容(line 18-441)逐字不变
|
||
- `npx tsc --noEmit` 不引入指向 `app/ai-model/page.tsx` 的新错误
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<verification>
|
||
## Plan 级整体验证
|
||
|
||
执行完两个任务后,运行下列**4 条**整体校验:
|
||
|
||
### 1. TS 编译(不引入新错误)
|
||
```bash
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
|
||
npx tsc --noEmit 2>&1 | tee /tmp/tsc.log
|
||
echo "新文件错误数(应为 0):"
|
||
grep -E "(lib/permissions\.ts|app/ai-model/page\.tsx)" /tmp/tsc.log | wc -l
|
||
```
|
||
**判定**:tsc 整体退出码可能为 2(存量 67 条错误,与 Phase 1 一致,本 phase 无关),但 grep 过滤后**0 条**指向 `lib/permissions.ts` 或 `app/ai-model/page.tsx`。
|
||
|
||
### 2. RBAC 矩阵正确性(5+1 角色逐一确认)
|
||
```bash
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
|
||
|
||
# 总计 'credential-slot' 命中数(union literal + 2 个角色数组 + 可能 1 行注释表 = 3 或 4)
|
||
grep -nE "['\"]credential-slot['\"]" lib/permissions.ts
|
||
|
||
# 反向校验:4 个不应该含 credential-slot 的角色数组
|
||
# 提取每个角色定义后的数组内容,检查不含 credential-slot
|
||
awk '/内容管理员: \[/,/\],/' lib/permissions.ts | grep "credential-slot" | wc -l # 期望 0
|
||
awk '/卡牌管理员: \[/,/\],/' lib/permissions.ts | grep "credential-slot" | wc -l # 期望 0
|
||
awk '/查看者: \[/,/\],/' lib/permissions.ts | grep "credential-slot" | wc -l # 期望 0
|
||
awk '/管理员: \[/,/\],/' lib/permissions.ts | tail -n +2 | grep "credential-slot" | wc -l # 期望 0(tail 跳过第一个匹配避免「AI模型管理员」干扰)
|
||
|
||
# getModuleFromPath 不变
|
||
grep -n '"ai-model": "ai-model"' lib/permissions.ts # 期望 1 行命中
|
||
grep -n "credential-slot" lib/permissions.ts | grep "pathMap\|getModuleFromPath" | wc -l # 期望 0(不在路径映射函数体中)
|
||
```
|
||
|
||
### 3. 入口控件 + 占位 Dialog 完整性(11 条 specifics)
|
||
```bash
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
|
||
|
||
# specifics 1-5 来自 lib/permissions.ts,已在上面 grep
|
||
# specifics 6-9, 11 来自 app/ai-model/page.tsx:
|
||
|
||
head -n 1 app/ai-model/page.tsx # 期望 "use client"
|
||
grep -cE "['\"]credential-slot['\"]" app/ai-model/page.tsx # 期望 ≥1(hasPermission 调用)
|
||
grep -c "KeyRound" app/ai-model/page.tsx # 期望 ≥2(import + JSX)
|
||
grep -c "凭据槽位" app/ai-model/page.tsx # 期望 ≥2(Button + DialogTitle)
|
||
grep -c "通用凭据槽位" app/ai-model/page.tsx # 期望 1(DialogTitle)
|
||
grep -c "对话框真实内容由 Phase 3 落地" app/ai-model/page.tsx # 期望 1
|
||
grep -c "setIsCredentialDialogOpen" app/ai-model/page.tsx # 期望 ≥3(useState + onClick + 通过 onOpenChange 间接传引用)
|
||
grep -c "useState" app/ai-model/page.tsx # 期望 ≥3(import + 2 调用)
|
||
grep -cE "from \"@/components/ui/dialog\"" app/ai-model/page.tsx # 期望 1
|
||
grep -cE "from \"@/lib/permissions\"" app/ai-model/page.tsx # 期望 1
|
||
```
|
||
|
||
### 4. 不引入新依赖
|
||
```bash
|
||
cd C:\Users\admin\Desktop\Lila-Server\qy-lty-admin
|
||
git diff --stat package.json yarn.lock package-lock.json pnpm-lock.yaml 2>/dev/null
|
||
# 期望:0 行输出(4 个文件均未改动)
|
||
```
|
||
|
||
**整体判定**:4 条全部通过 → Plan 02-01 收尾,可移交 Plan 02-02(修改记录追加)。
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
**ROADMAP.md Phase 2 Success Criteria 4 条对照(前 3 条由 Plan 02-01 + Plan 02-02 共同覆盖;第 4 条由本 plan 独立覆盖):**
|
||
|
||
1. ✅ `lib/permissions.ts` PermissionModule union 含 `'credential-slot'`;超级管理员 + AI模型管理员含、其他角色不含;`hasPermission('credential-slot')` 在两类账户下返回 true,其他角色 false
|
||
2. ✅ `getModuleFromPath('/ai-model')` 行为不变,无新菜单项(不动 sidebar)
|
||
3. ✅ `/ai-model` 页面工具栏区域可见明确的「凭据槽位」入口控件(在 DashboardHeader children 内、与「添加新模型」按钮同行右侧);未授权角色 DOM 中不存在
|
||
4. ✅ 入口控件可见性走 `hasPermission('credential-slot')`,不直接读 `localStorage.user_role`;点击入口控件触发占位 Dialog 打开(DialogTitle「通用凭据槽位」+ DialogDescription「对话框真实内容由 Phase 3 落地」)
|
||
</success_criteria>
|
||
|
||
<output>
|
||
完成后由 Plan 02-02 在收尾任务中创建 `.planning/phases/02-rbac-ai/02-01-SUMMARY.md`(与 Plan 02-02 的 SUMMARY 各自独立)。
|
||
</output>
|