31 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-rbac-ai | 01 | execute | 1 |
|
true |
|
|
Purpose:让授权运营立即能在大模型管理页看到入口控件,未授权角色彻底看不到(DOM 中完全不存在);为 Phase 3 的真实表单落地预留 Dialog 挂载点。
Output:lib/permissions.ts(修改)+ app/ai-model/page.tsx(修改)。无新依赖、不动 lockfile。
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.tsxlib/permissions.ts 当前结构(VERIFIED 全文 123 行)
// 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 行)
// 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 行)
// 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)
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)
"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。不加守卫会出现「超管刷新页面瞬间按钮闪一下消失再出现」的水合警告。
<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>
**改动 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
- 不要重排数组顺序(仅在数组**末尾**追加)
<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>
<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>
**改动 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+ 即存在)
<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>
执行完两个任务后,运行下列4 条整体校验:
1. TS 编译(不引入新错误)
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 角色逐一确认)
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)
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. 不引入新依赖
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(修改记录追加)。
<success_criteria> ROADMAP.md Phase 2 Success Criteria 4 条对照(前 3 条由 Plan 02-01 + Plan 02-02 共同覆盖;第 4 条由本 plan 独立覆盖):
- ✅
lib/permissions.tsPermissionModule union 含'credential-slot';超级管理员 + AI模型管理员含、其他角色不含;hasPermission('credential-slot')在两类账户下返回 true,其他角色 false - ✅
getModuleFromPath('/ai-model')行为不变,无新菜单项(不动 sidebar) - ✅
/ai-model页面工具栏区域可见明确的「凭据槽位」入口控件(在 DashboardHeader children 内、与「添加新模型」按钮同行右侧);未授权角色 DOM 中不存在 - ✅ 入口控件可见性走
hasPermission('credential-slot'),不直接读localStorage.user_role;点击入口控件触发占位 Dialog 打开(DialogTitle「通用凭据槽位」+ DialogDescription「对话框真实内容由 Phase 3 落地」) </success_criteria>