57 KiB
Raw Blame History

Phase 2RBAC 收敛 + AI 模型页入口 - Research

Researched: 2026-05-08 Domain: 前端 RBAC 矩阵扩展 + Next.js 客户端页面入口控件 + shadcn Dialog 占位 Confidence: HIGH所有改动文件已直接 read 完整源码 + 全仓 grep 验证;不引入新依赖、不引入新外部服务)

Summary

本 phase 是 Milestone v1.0「通用凭据槽位前端集成」的第二步:在 lib/permissions.ts'credential-slot' 加入 PermissionModule union 与 PERMISSION_MATRIX(仅给「超级管理员」+「AI模型管理员」并在 app/ai-model/page.tsxDashboardHeader 子节点位置渲染受 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.tsxvariant="outline" 或现有页面其他按钮的 variant
  • 位置:在 app/ai-model/page.tsx页面顶部 / 头部 / 工具栏区域researcher 必须 read 现有页面结构给出具体插入点 —— 最可能是页面头部的标题旁边或现有"添加 AI 模型"之类按钮的同行右侧)
  • 图标KeyRoundLucide—— 凭据语义最贴切;如果不可用降级到 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.tsxcomponents/ui/dialog.tsxlucide-reactlib/permissions.ts:hasPermission
  • 不引入新依赖(不动 lockfile不跑 npm install
  • 不动 lib/permissions.ts 的其他函数(getUserRole / hasPathPermission 等)

修改记录

qy-lty-admin/docs/修改记录.md 顶部追加一条 Phase 2 条目:

  • 文件路径:lib/permissions.tsapp/ai-model/page.tsx
  • 修改类型:新增 / 修改
  • 跨项目联动:「无 — Phase 2 是纯前端 RBAC + UI 入口落地,不引入新跨项目契约;后端 commit 46d72b8 已建立的互引仍有效Phase 3 引入实质 PUT 调用时若涉及新契约再评估」

Claude's Discretion

  • 入口 Button 的具体 variantoutline / default / secondary—— planner 看现有页面其他按钮风格选最一致的
  • 入口 Button 的 size(默认 / sm / lg—— 同上
  • 占位对话框的 DialogContent className 大小 —— 用默认即可
  • 是否给 Button 加 tooltipKeyRound 图标语义不够明显时)—— 推荐加,让运营秒懂

Deferred Ideas (OUT OF SCOPE)

  • 编辑对话框真实表单RHF + Zod + 提交逻辑) — Phase 3 / CRED-FE-04
  • Sonner toast 反馈 — Phase 3 / CRED-FE-05
  • components/ai-model/CredentialSlotDialog.tsx 抽离 — Phase 3
  • 真实后端 PUT 调用 — Phase 3
  • 端到端联调 — Phase 3 后 </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
CRED-FE-02 RBAC 模块声明:lib/permissions.ts 加入 credential-slot 模块 keyPermissionModule 类型扩充);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 modeopen + onOpenChange)让对话框开关由 useState 单源驱动;对话框内容此 phase 内联Phase 3 才抽离
修改记录追加 文档层(docs/修改记录.md CLAUDE.md 强制:每次修改必须在同一会话追加到顶部

Standard Stack

Core已在 deps本 phase 沿用)

Library Version Purpose Why Standard
react 19 useState Phase 2 仅用 useState<boolean> [CITED: package.json]
next 15.2.4 App Router app/ai-model/page.tsx 已是 App Router 页面(添加 "use client" 后可用 hooks[CITED: package.json + STACK.md]
@radix-ui/react-dialog latest Dialog primitive components/ui/dialog.tsx 已封装 shadcn 风格 [VERIFIED: components/ui/dialog.tsx line 4]
class-variance-authority latest Button variants components/ui/button.tsx 用 cva 定义 6 个 variant + 4 个 size [VERIFIED: components/ui/button.tsx line 3]
lucide-react ^0.454.0 图标库(含 KeyRound [VERIFIED: package.json line 50 + yarn.lock line 1493] —— KeyRound 是 lucide-react 240+ 图标之一 [CITED: lucide.dev/icons/key-round]

Supporting项目已有本 phase 引用)

Library Version Purpose When to Use
@/lib/permissions local hasPermission(module) 入口可见性判断
@/components/ui/button localshadcn 复制粘贴) Button + variant 入口按钮样式
@/components/ui/dialog localshadcn 复制粘贴) Dialog + Header/Title/Description 占位对话框

Alternatives Considered

Instead of Could Use Tradeoff
内联 Dialog 在 app/ai-model/page.tsx 立即抽到 components/ai-model/CredentialSlotDialog.tsx CONTEXT.md 显式锁定内联Phase 3 才抽离)—— 不要在 Phase 2 提前抽离
KeyRound Lock / Settings CONTEXT.md 显式说"如果不可用降级到 Lock / Settings"。KeyRound 在 lucide-react 0.454.0 中确实存在 [CITED: lucide.dev/icons/key-round] 且 ^0.454.0 已在 deps无需降级
Card 入口 Button 入口 CONTEXT.md 锁定 Button(最简)

Installation:

无需安装 —— Phase 2 不引入任何新依赖。KeyRound 是 lucide-react 0.454.0 的标准图标lucide 从 0.298+ 即已包含 KeyRound[CITED: lucide.dev/icons/key-round]

Version verification:

# 已通过仓库内文件确认,无需联网
grep -n '"lucide-react"' package.json   # → "lucide-react": "^0.454.0"
grep -rn "from \"lucide-react\"" components/ app/  # → 50+ 个文件已 import lucide-react

[VERIFIED: package.json line 50] lucide-react ^0.454.0 已锁定。

Architecture Patterns

System Architecture Diagram

┌──────────────────────────────────────────────────────────────────────┐
│ Browser已认证用户访问 /ai-model                                    │
└──────────────────────────────────────────────────────────────────────┘
            │
            │ 1. middleware.ts cookie auth_token check已存在
            ▼
┌──────────────────────────────────────────────────────────────────────┐
│ DashboardShellcomponents/dashboard-shell.tsx                      │
│ - hasPathPermission(pathname) → /ai-model 映射到 module 'ai-model'    │
│ - 'ai-model' 在「超级管理员」「AI模型管理员」两个角色的矩阵中            │
└──────────────────────────────────────────────────────────────────────┘
            │
            │ 2. 路径级权限通过 → 渲染子内容
            ▼
┌──────────────────────────────────────────────────────────────────────┐
│ app/ai-model/page.tsx"use client" 转换后)                          │
│                                                                      │
│  DashboardHeader heading="大模型管理">                                │
│    [现有] <Button>添加新模型</Button> pink-purple gradient         │
│    [新增] {hasPermission('credential-slot') && (                     │
│              <Button variant="outline" onClick={openDialog}>          │
│                <KeyRound /> 凭据槽位                                   │
│              </Button>                                                │
│            )}                                                         │
│  </DashboardHeader>                                                  │
│                                                                      │
│  ... Tabs ...                                                        │
│                                                                      │
│  [新增] <Dialog open={isOpen} onOpenChange={setIsOpen}>               │
│           <DialogContent>                                             │
│             <DialogHeader>                                            │
│               <DialogTitle>通用凭据槽位</DialogTitle>                   │
│               <DialogDescription>                                     │
│                 对话框真实内容由 Phase 3 落地                            │
│               </DialogDescription>                                    │
│             </DialogHeader>                                           │
│           </DialogContent>                                            │
│         </Dialog>                                                     │
└──────────────────────────────────────────────────────────────────────┘
            │
            │ 3. hasPermission('credential-slot') 内部
            ▼
┌──────────────────────────────────────────────────────────────────────┐
│ lib/permissions.ts本 phase 改动文件之一)                            │
│ getUserRole() → 读 localStorage.user_role → RoleName                  │
│ getAllowedModules() → PERMISSION_MATRIX[role]                        │
│ hasPermission('credential-slot') → 数组 includes 检查                 │
│                                                                      │
│ 改动:                                                                 │
│ - PermissionModule union +1: 'credential-slot'                       │
│ - 超级管理员 数组 +1: 'credential-slot'                                │
│ - AI模型管理员 数组 +1: 'credential-slot'                              │
│ - 内容管理员/卡牌管理员/查看者/管理员:不变                              │
│ - getModuleFromPath不变                                             │
└──────────────────────────────────────────────────────────────────────┘
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

// Source: components/sidebar.tsx
"use client"
import { useState, useEffect } from "react"
import { hasPermission } from "@/lib/permissions"

export function Sidebar() {
  const [mounted, setMounted] = useState(false)
  useEffect(() => { setMounted(true) }, [])

  const visibleAiItems = mounted
    ? aiMenuItems.filter((item) => hasPermission(item.module))
    : []
  // ... 同理过滤其他菜单组
}

[VERIFIED: components/sidebar.tsx line 83-104]

Pattern 2shadcn Dialog controlled mode

What: components/ui/dialog.tsxDialog = DialogPrimitive.Root 直通导出,因此支持 Radix Dialog 的 open + onOpenChange controlled props。

When to use: 当点击逻辑由父组件持有(本 phase点击 Button → setOpen(true)),用 controlled mode不要用 <DialogTrigger> 包按钮(会在 DOM 中嵌套)。

Example

// 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 modeopen + onOpenChange)更清晰,也方便 Phase 3 加"打开时自动 GET"逻辑。
  • 不要在 SSR 阶段调 hasPermission:会读不到 localStorage 一律降级到「查看者」,导致按钮总是不渲染、即使是超管也看不到。务必走 useEffect mounted 守卫,复用 components/sidebar.tsx 模式。
  • 不要直接读 localStorage.user_rolelocalStorage.is_superuserCONTEXT.md 锁定走 hasPermission('credential-slot'),不直接读字符串。
  • 不要把 'credential-slot' 加进其他 4 个角色数组:内容管理员 / 卡牌管理员 / 查看者 / 管理员 的数组逐字不动
  • 不要动 getModuleFromPath:路径级映射保持现状,凭据槽位是 /ai-model 页面内嵌子能力。
  • 不要新建独立 Dialog 组件文件:内联在 app/ai-model/page.tsxPhase 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 1app/ai-model/page.tsx 当前是 Server Component

What goes wrong 现有 app/ai-model/page.tsxline 1-7没有 "use client" 指令,是 Server Component。直接加 useState 会编译报错。

Why it happens STACK.md + ARCHITECTURE.md 都指出大部分页面因需要 hooks 是 client/ai-model 当前是纯展示静态卡片mock 数据硬编码),所以一直是 server component。

How to avoid 在文件 line 1 顶部加 "use client",并补 import { useState } from "react"

Warning signs useState is not a function / cannot use hooks in a server component 编译错误。

[VERIFIED: app/ai-model/page.tsx line 1-7 没有 "use client"]

Pitfall 2水合不匹配hydration mismatch

What goes wrong 加上 "use client" 后,hasPermission('credential-slot') 在 SSR 阶段(typeof window === "undefined")返回 false默认「查看者」但 hydration 后 localStorage 有「超级管理员」时返回 trueDOM 不一致。

Why it happens lib/permissions.ts:65-67getUserRole() 在 SSR 一律 fallback「查看者」。

How to avoid 复用 components/sidebar.tsx:83-104 的 mounted 守卫模式:const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []),然后用 {mounted && hasPermission('credential-slot') && <Button>...</Button>} 包裹。

Warning signs Next.js dev console 出现 Hydration failed warning或者刷新页面瞬间按钮闪一下消失再出现。

Pitfall 3把按钮放在 DashboardHeader 之外

What goes wrong 现有 DashboardHeader 已经按 children 渲染右侧 actionsline 11-16 现有「添加新模型」按钮就放在 children 位置)。如果新按钮放在 <DashboardHeader> 后、<Tabs> 前,会形成游离的工具栏行,与现有视觉层级不一致。

How to avoid 把"凭据槽位"按钮放到 DashboardHeader children 内,在「添加新模型」按钮右侧(同行)。

Warning signs 视觉上按钮浮空、与页面顶部留白割裂。

Pitfall 4Dialog 嵌在 DashboardHeader children 内

What goes wrong 如果把 <Dialog> JSX 也放进 DashboardHeader children会与现有 buttons 同级,引发 portal 边界问题Radix Portal 会绕到 body 末尾,理论上不影响渲染但破坏组件树可读性)。

How to avoid <Dialog> 作为 <DashboardShell>最后一个兄弟节点(在 </Tabs> 之后、</DashboardShell> 之前),与 Header 内的 trigger Button 用 useState 通过 open prop 联动。

Pitfall 5忘记给 [ASSUMED]"use client" 后的 import 顺序

What goes wrong Next.js App Router 要求 "use client" 必须是文件第一行(在所有 import 之前)。

How to avoid 文件结构应是:

"use client"          // line 1

import { useState } from "react"   // line 2-x
import { ... } from "..."
// ...

Code Examples

lib/permissions.ts 改动 patch提示式非最终代码

// 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 改动骨架

// Source 模板app/ai-model/page.tsx 现状line 1-16+ components/sidebar.tsx mounted 模式
"use client"   // ← 新增 line 1顶置

import { useState, useEffect } from "react"   // ← 新增
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"   // ← 新增
import {
  Brain, Mic, Database, Plus, Sparkles, Edit, Play, Sliders, User,
  KeyRound,   // ← 新增
} from "lucide-react"
import { hasPermission } from "@/lib/permissions"   // ← 新增

export default function AIModelPage() {
  const [mounted, setMounted] = useState(false)
  const [isCredentialDialogOpen, setIsCredentialDialogOpen] = useState(false)

  useEffect(() => { setMounted(true) }, [])

  return (
    <DashboardShell>
      <DashboardHeader heading="大模型管理" text="管理洛天依的AI模型、语音和知识库">
        <Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
          <Plus className="mr-2 h-4 w-4" />
          添加新模型
        </Button>
        {/* 凭据槽位入口(受 RBAC 收敛) */}
        {mounted && hasPermission("credential-slot") && (
          <Button
            variant="outline"
            onClick={() => setIsCredentialDialogOpen(true)}
          >
            <KeyRound className="mr-2 h-4 w-4" />
            凭据槽位
          </Button>
        )}
      </DashboardHeader>

      <Tabs defaultValue="framework" className="space-y-4">
        {/* ...原有 Tabs 内容完全保留... */}
      </Tabs>

      {/* 占位 DialogPhase 3 替换为真实表单) */}
      <Dialog
        open={isCredentialDialogOpen}
        onOpenChange={setIsCredentialDialogOpen}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>通用凭据槽位</DialogTitle>
            <DialogDescription>
              对话框真实内容由 Phase 3 落地
            </DialogDescription>
          </DialogHeader>
        </DialogContent>
      </Dialog>
    </DashboardShell>
  )
}

[VERIFIED: 模板基于 app/ai-model/page.tsx 当前 line 1-16 + components/sidebar.tsx line 83-104 mounted 模式]

State of the Art

Old Approach Current Approach When Changed Impact
在 React 组件里直接 localStorage.getItem("user_role") === "超级管理员" 通过 hasPermission(module) 项目初始化即如此 单一来源、TS literal 校验、避免硬编码
用 DialogTrigger 包 Buttonuncontrolled controlled modeopen + 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 / Settingsplan 阶段需在 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.tsxapp/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/useEffectDialog 子组件KeyRoundhasPermission

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.jsonstrict mode
Quick run command npm run lint(实际是 next lint
Full suite command npx tsc --noEmit && npm run lint
Phase gate npx tsc --noEmit exit 0、grep 11 条 specifics 全命中

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
CRED-FE-02 PermissionModule'credential-slot' literal static-grep grep -nE "['\"]credential-slot['\"]" lib/permissions.ts | wc -l(期望 ≥3union literal +「超级管理员」数组 +「AI模型管理员」数组 lib/permissions.ts 已存在
CRED-FE-02 TS 编译通过union 扩展无碎片) compile npx tsc --noEmit 全仓
CRED-FE-02 其他角色不含 'credential-slot' static-grep反向 grep + 视觉核对:grep -nE "credential-slot" lib/permissions.ts 结果共 4 处type union literal + 2 角色数组 + 可能 1 处注释/空行);不含「内容管理员」「卡牌管理员」「查看者」「管理员」字段下方数组中
CRED-FE-02 getModuleFromPath('/ai-model') 行为不变 structural-grep grep -n '"ai-model": "ai-model"' lib/permissions.ts(期望 1 处命中且周边代码不变)
CRED-FE-03 页面含 "凭据槽位" 文本 static-grep grep -n "凭据槽位" app/ai-model/page.tsx ≥1按钮文案+ ≥1DialogTitle app/ai-model/page.tsx 已存在
CRED-FE-03 页面含 KeyRound import 与使用 static-grep grep -n "KeyRound" app/ai-model/page.tsx ≥2import + JSX
CRED-FE-03 页面含 hasPermission("credential-slot") 包裹 static-grep grep -nE 'hasPermission\(["\\']credential-slot' app/ai-model/page.tsx ≥1
CRED-FE-03 页面含 Dialog 占位(<DialogTitle>通用凭据槽位</DialogTitle> static-grep grep -n "通用凭据槽位" app/ai-model/page.tsx ≥1
CRED-FE-03 浏览器实测:超级管理员看见入口、查看者看不见 manual-only 登录两类账户访问 /ai-model 视觉核对

Sampling Rate

  • Per task commit: npx tsc --noEmit(≤ 30s+ grep 单点校验(< 1s
  • Per wave merge: npm run lint && npx tsc --noEmit(≤ 60s
  • Phase gate: 全部 specifics 11 条 grep 命中 + npx tsc --noEmit exit 0 + 修改记录顶部含 Phase 2 条目

Wave 0 Gaps

  • 无 —— 项目无单元测试,且本期不引入 Vitest明确 deferred。沿用 Phase 1 的「tsc --noEmit + lint + grep + 浏览器手动核对」回路即可。

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no已由 AUTH-01..06 + middleware 覆盖Phase 2 不动)
V3 Session Management no同上
V4 Access Control yes(核心) lib/permissions.ts:hasPermission 角色矩阵 + DashboardShell.hasPathPermission 路由级Phase 2 是给该矩阵新增一个 module key
V5 Input Validation noPhase 2 无表单输入;占位 Dialog 不收集任何输入)
V6 Cryptography no

Known Threat Patterns for Next.js 15 + 客户端 RBAC

Pattern STRIDE Standard Mitigation
仅前端校验导致越权(修改 localStorage.user_role 即可绕过) Elevation of Privilege 后端独立校验PERM-06CONCERNS.md 极高严重级;本期 Active 范围内不消化,但 Phase 3 实际调用 /v1/admin/credential-slot/ PUT 时后端必须重新校验角色)
隐藏按钮但能直接 fetch API Information Disclosure 同上,靠后端 RBAC 闭环Phase 2 内的"DOM 中完全不存在"只是 UX 礼貌,不是安全边界
Hydration mismatch 导致按钮短暂渲染给所有人 Information Disclosure mounted 守卫模式(见 Pitfall 2

关键提醒: lib/permissions.ts 的 RBAC 是前端 UI 层保护,绝不是真实安全边界。CRED-FE-02 + CRED-FE-03 是 UI 层「可见性收敛」,最终安全闭环由后端在 Phase 3 实际调用时由 qy_lty Django 端的 admin 中间件 + 视图级权限装饰器完成。Phase 2 的 success criteria 只校验"DOM 中是否渲染"和"调用 hasPermission 时返回是否正确"校验后端权限闭环。

Project Constraints (from CLAUDE.md)

  • 沟通中文用户偏好中文沟通注释、commit message、面向用户的回答必须中文。RESEARCH.md / PLAN.md 等文档已强制中文(见 user memory feedback_gsd_chinese.md)。
  • 修改记录强制:每次代码改动必须在同一会话内追加到 qy-lty-admin/docs/修改记录.md 顶部(最新在前),按头部「修改格式说明」格式(日期 / 文件路径 / 修改类型 / 修改内容 / 修改原因)。
  • 跨仓库联动单写:本仓库记录前端改动;后端改动写在 qy_lty/docs/修改记录.md;联动改动两边各写一条相互引用,不混在同一条
  • 包管理器不混用:项目同时存在 package-lock.json + pnpm-lock.yaml + yarn.lock,本期 v1.0 不收敛(候选优先级第 4 条「高」级,本期不消化);本 phase 不要新增依赖、不要触发任何包管理器命令。
  • shadcn 复制粘贴模式components/ui/ 是源码副本,不是 npm 包;可直接修改但本 phase 不要components/ui/dialog.tsxcomponents/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

export type PermissionModule =
  | "dashboard"
  | "users"
  | "permissions"
  | "ai-model"
  | "outfits"
  | "props"
  | "home-decor"
  | "food"
  | "songs"
  | "dances"
  | "achievements"
  | "affinity"
  | "settings";

RoleName unionline 18

export type RoleName = "超级管理员" | "内容管理员" | "AI模型管理员" | "卡牌管理员" | "查看者" | "管理员";

注意: RoleName6 个角色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

export function hasPermission(module: PermissionModule): boolean {
  return getAllowedModules().includes(module);
}

底层链路:hasPermissiongetAllowedModules()getUserRole()localStorage.getItem("user_role") →(找不到则 fallback「查看者」PERMISSION_MATRIX[role]

getModuleFromPath(pathname) 实现line 92-113

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

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 的第二个子节点,紧跟「添加新模型」按钮)

      <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 用默认(不传 sizedefault,与"添加新模型"同高 h-10

Dialog 占位插入点: line 442</Tabs> 之后、line 443</DashboardShell> 之前)—— 作为 <DashboardShell> children 的最后一个兄弟节点:

      </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 引入的两个 useStatemountedisCredentialDialogOpen)是该文件首次 state 使用。
  • 本文件没有任何 <Dialog> 用法 —— Phase 2 新增的占位 Dialog 是该文件首次 Dialog 使用。

现状证据:components/ui/dialog.tsxAPI 验证)

[VERIFIED: components/ui/dialog.tsx line 1-123, 全文已 read]

Export 列表line 111-122

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

DialogContentline 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.tsxAPI 验证)

[VERIFIED: components/ui/button.tsx line 1-57, 全文已 read]

variant 选项line 11-21

Variant className 用途
default bg-primary text-primary-foreground hover:bg-primary/90 主 CTA默认
destructive bg-destructive ... 删除/危险操作
outline border border-input bg-background hover:bg-accent hover:text-accent-foreground 次要操作(推荐用于凭据槽位入口)
secondary bg-secondary ... 次级强调
ghost hover:bg-accent ... 浅色背景按钮(如 Sidebar 菜单)
link text-primary underline-offset-4 hover:underline 文字链接式

size 选项line 22-27

Size className
default h-10 px-4 py-2(推荐用于本 phase
sm h-9 rounded-md px-3
lg h-11 rounded-md px-8
icon h-10 w-10

Default variant + sizeline 28-32

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 KeyRound0 命中(除 CONTEXT.md 自身的 3 处提及)。
  • 全仓 grep lucide-react70+ 文件 import lucide-reactconfirmed 在 deps 中)。
  • package.json line 50"lucide-react": "^0.454.0"yarn.lockpackage-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

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

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 阶段误判。

推荐复用模式(凭据槽位按钮)

// 模板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 风格)

### [2026-05-08] Phase 2前端RBAC 收敛 + AI 模型页入口

配套服务端 PhasePhase 2 是纯前端 RBAC + UI 入口落地)
覆盖前端需求CRED-FE-02、CRED-FE-03

- **文件路径**
  - `lib/permissions.ts`(修改)
  - `app/ai-model/page.tsx`(修改)
- **修改类型**修改RBAC 矩阵扩展 + 页面入口控件 + 占位 Dialog
- **修改内容**
  - `lib/permissions.ts``PermissionModule` union 末尾追加 `'credential-slot'` 字面量;`PERMISSION_MATRIX` 中"超级管理员"与"AI模型管理员"两个角色的数组末尾追加 `'credential-slot'`;其他 4 个角色(内容管理员/卡牌管理员/查看者/管理员)数组逐字保持不变;`getModuleFromPath` 完全不动(凭据槽位是 `/ai-model` 内嵌子能力,不占独立路由)
  - `app/ai-model/page.tsx`:顶部加 `"use client"` 指令;新增 `useState` / `useEffect` import新增 `KeyRound` lucide 图标 import新增 `Dialog` 子组件 import新增 `hasPermission` 工具 import`DashboardHeader` children 中"添加新模型"按钮右侧加入受 `mounted && hasPermission('credential-slot')` 收敛的"凭据槽位" `<Button variant="outline">`,点击 setState 打开占位 Dialog`<Tabs>` 后内联占位 `<Dialog open onOpenChange>`,含 `DialogTitle="通用凭据槽位"` + `DialogDescription="对话框真实内容由 Phase 3 落地"`,无表单
- **修改原因**
  - 落地 Milestone v1.0 Phase 2让授权运营超管 / AI模型管理员能在 `/ai-model` 看到凭据槽位入口,未授权角色 DOM 中完全不渲染按钮(不是 disable
  - 沿用 `components/sidebar.tsx` 已有的 `mounted` 守卫 + `hasPermission` 模式,避免水合不匹配
  - Dialog 占位(不实现表单)—— 表单逻辑由 Phase 3 / CRED-FE-04 落地,现先验证"按钮 + Dialog 联动"链路
- **跨项目联动**:无 — Phase 2 是纯前端 RBAC + UI 入口落地,不引入新跨项目契约;后端 commit 46d72b8 已建立的互引仍有效Phase 3 引入实质 PUT 调用时若涉及新契约再评估
- **服务端联动**:同上「跨项目联动」字段

Sources

PrimaryHIGH 置信,全部来自仓库内文件直接 read

  • lib/permissions.ts line 1-123完整文件已 read — RBAC union + 矩阵 + 工具函数
  • app/ai-model/page.tsx line 1-446完整文件已 read — 现有页面结构 + 按钮风格 + 插入点
  • components/ui/dialog.tsx line 1-123完整文件已 read — Dialog API 与子组件 export
  • components/ui/button.tsx line 1-57完整文件已 read — Button variant + size cva
  • components/sidebar.tsx line 1-193完整文件已 readhasPermission + 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.mdARCHITECTURE.md — 代码库结构与架构

SecondaryMEDIUM 置信,已与官方源交叉)

  • lucide.dev KeyRound 图标存在性 — Lucide 官网图标列表(图标自 0.298+ 即存在)

TertiaryLOW 置信)

  • 无 —— 本 phase 不依赖任何外部库的未读文档;所有 API 都在仓库内 shadcn 复制粘贴源码中已验证。

Metadata

Confidence breakdown:

  • Standard stack: HIGH — 所有依赖已 read 源码或 grep package.json 确认
  • Architecture: HIGH — 现有 components/sidebar.tsx mounted 模式已有 production 验证
  • Pitfalls: HIGH — Pitfall 1"use client" 缺失)已通过 read line 1-7 直接确认Pitfall 2hydration由 sidebar.tsx 已有 mounted 守卫反推Pitfall 3按钮位置由 line 11-16 现有结构推导Pitfall 4 / 5 是 Next.js 通用知识 [CITED: nextjs.org/docs/app/api-reference/directives/use-client]

Research date: 2026-05-08 Valid until: 2026-06-0730 天 —— 仓库内文件,依赖稳定;如 Phase 3 启动前 lib/permissions.ts 或 app/ai-model/page.tsx 有手工改动,需要重读)