Commit 24c4cc97 authored by yaoke.yk's avatar yaoke.yk

init: add design docs and frontend prototype

parents
# ===== 排除的文件夹 =====
Toonflow-app/
aibuildvideo/
waoowaoo/
.claude/
# ===== 敏感配置(本地密钥) =====
**/application-local.yml
**/.env
**/.env.local
# ===== Java / Maven =====
**/target/
**/.mvn/wrapper/maven-wrapper.jar
*.class
*.jar
*.war
*.ear
*.log
# ===== Node.js / 前端 =====
**/node_modules/
**/.next/
**/dist/
**/.turbo/
**/build/
**/.cache/
# ===== IDE =====
.idea/
*.iml
.vscode/
*.suo
*.user
# ===== OS =====
.DS_Store
Thumbs.db
desktop.ini
# ===== 临时文件 =====
*.tmp
*.bak
*.swp
# AI 漫剧 SaaS 平台 — 设计文档索引
> 生成时间: 2026-04-16
> 技术栈: Java 17 + Spring Boot 3.x + MyBatis-Plus + Spring AI Alibaba + MySQL + Redis Stack (缓存 + 向量) + 阿里云 OSS / 火山引擎 TOS(主)+ MinIO(备选)
> 前端参考: `doc/html` (Vite + React + Shadcn)
> 开源参考: Toonflow-app、AIComicBuilder、waoowaoo
## 文档结构
| # | 文档 | 说明 |
|---|------|------|
| 01 | [产品定位与 SaaS 化策略](./01-产品定位与SaaS化策略.md) | 目标用户、商业模式、核心差异化 |
| 02 | [前端功能与页面清单](./02-前端功能与页面清单.md) | 基于 doc/html 原型的完整功能规格 |
| 03 | [三开源项目对比与吸收](./03-三开源项目对比与吸收.md) | Toonflow / AIComicBuilder / waoowaoo 取长补短 |
| 04 | [核心业务流程与 AI 流水线](./04-核心业务流程与AI流水线.md) | 剧本→大纲→分集→角色→分镜→视频 端到端流程 |
| 05 | [后端架构设计](./05-后端架构设计.md) | Spring Boot 模块划分、分层、核心组件 |
| 06 | [数据库设计](./06-数据库设计.md) | MySQL 表结构 + Redis Stack 向量存储 |
| 07 | [API 接口规范](./07-API接口规范.md) | REST + WebSocket/SSE 全量接口定义 |
| 08 | [异步任务与 AI 工作流](./08-异步任务与AI工作流.md) | 三层 Agent + 任务队列 + Skill 系统 |
| 09 | [计费与多租户基础设施](./09-计费与多租户SaaS基础设施.md) | 余额冻结、Saga 事务、租户隔离 |
| 10 | [部署与运维](./10-部署与运维.md) | Docker、监控、可观测性 |
| 11 | [实施路线图](./11-实施路线图与里程碑.md) | 分阶段交付计划 |
| 12 | [前端原型复盘与流程验证](./12-前端原型复盘与流程验证.md) | 17 页面清单、设计文档对齐度、技术债务 |
| 13 | [多 Agent 功能复盘与落地方案](./13-多Agent功能复盘与落地方案.md) | 三项目 Agent 源码分析、Java 落地方案、吸收策略 |
| 14 | [前端原型截图复盘与最终确认](./14-前端原型截图复盘与最终确认.md) | 34 截图映射、页面覆盖率、原型与设计文档差异 |
| 15 | [执行任务列表](./15-执行任务列表.md) | M0~M9 全量任务清单,~302 项,完成即标记 |
## 一句话总结
**做一个面向短剧工作室和内容创作者的 Web SaaS,核心能力是"剧本一键产出完整动画短剧视频"。后端用 Java + Spring AI Alibaba(通义系列)承载核心 AI 流水线,吸收 Toonflow 的三层 Agent 架构、AIComicBuilder 的角色一致性方案、waoowaoo 的 SaaS 工程化基础设施,实现 `doc/html` 原型定义的全部功能。**
# 01 · 产品定位与 SaaS 化策略
# 01 · 产品定位与 SaaS 化策略
## 1. 产品定义
**产品名(暂定)**: YaoAI 漫剧工坊 (YaoAI Comic Studio)
**一句话定位**: 给内容创作者的 AI 漫剧生产 SaaS —— 上传小说/剧本,自动产出完整动画短剧视频,从剧本解析、角色设定、分镜制作到视频合成全流程可视化、可编辑、可协作。
**对标**:
- 国外: Runway、Pika、LTX Studio
- 国内: 即梦短剧、AIComicBuilder(开源)、Toonflow(开源)
- 我们的差异化: **中文短剧场景深度优化 + 企业级协作 + 通义系列原生集成 + 资产复用库**
## 2. 目标用户
| 层级 | 画像 | 关键诉求 |
|------|------|----------|
| **C 端创作者** | 网文作者、短视频博主、独立漫画师 | 低成本、快速出片、无需美术基础 |
| **小型工作室** | 5~20 人短剧 MCN、同人社团 | 多人协作、资产复用、品质可控 |
| **企业客户** | 出版社、游戏公司、教育机构 | 私有化部署、品牌安全、批量产出 |
## 3. 核心价值主张
1. **剧本一键开工**: 上传小说 → AI 自动提取角色/场景/关系,智能分集,无需手动拆分。
2. **角色一致性**: 借鉴 AIComicBuilder 的"四视图参考 + 文字约束"双轨制,角色在 1000+ 镜头中保持同一外形。
3. **三层 Agent 质量管控**: 借鉴 Toonflow,决策/执行/监督三层 Agent 协作,AI 产出有审核有修订。
4. **所见即所得的分镜工作台**: 拖拽排序、批量生成、实时预览、版本回溯。
5. **企业级 SaaS 基础**: 多租户隔离、按量计费、团队权限、资产库共享、审计日志。
## 4. 商业模式
### 4.1 套餐
| 套餐 | 月费 | 积分 | 团队人数 | 功能 |
|------|------|------|---------|------|
| Free | ¥0 | 100 | 1 | 基础生成、带水印 |
| Pro | ¥99 | 3000 | 5 | 去水印、资产库、参考图模式 |
| Team | ¥499 | 20000 | 20 | 团队协作、角色版本管理、API |
| Enterprise | 定制 | 定制 | 无限 | 私有部署、SLA、专属客服、定制模型 |
### 4.2 计费单位 (积分)
| 操作 | 单价(积分) | 说明 |
|------|-----------|------|
| 文本生成(大纲/分镜拆分) | 1~5 | 按 token |
| 角色四视图生成 | 20 | 一次性 |
| 场景/道具图 | 10 | 单张 |
| 分镜首尾帧 | 15 × 2 | 首+尾 |
| 视频生成 (5s, 720p) | 50 | 单镜头 |
| 视频生成 (8s, 1080p) | 120 | 单镜头 |
| TTS 配音 | 0.1 / 字符 | |
| 口型同步 | 30 | 单镜头 |
**补充充值**: ¥1 = 10 积分
## 5. 核心差异化(vs 三个开源项目)
| 维度 | Toonflow | AIComicBuilder | waoowaoo | **我们** |
|------|----------|----------------|----------|----------|
| 部署形态 | Electron 桌面 | Web 本地 | Web 本地 | **云 SaaS + 私有化可选** |
| 多租户 | ❌ | ❌ | ⭐ 基础 | ⭐⭐⭐ 企业级 |
| 计费 | ❌ | ❌ | ⭐ 账户冻结 | ⭐⭐⭐ 完整订阅+按量 |
| Agent 架构 | ⭐⭐⭐ 三层 | ⭐ 单层 | ⭐⭐ LangGraph | **⭐⭐⭐ 三层 + 可观测** |
| 角色一致性 | ⭐⭐ | ⭐⭐⭐ 双轨制 | ⭐⭐ | **⭐⭐⭐ 四视图+向量检索** |
| 视频模型 | Sora/豆包 | Seedance/Veo/Kling | FAL/百炼 | **通义万相+可插拔多模型** |
| 中文优化 | ⭐⭐ | ⭐ | ⭐⭐ | **⭐⭐⭐ 原生中文短剧** |
| 团队协作 | ❌ | ❌ | ⭐ | **⭐⭐⭐ 角色/权限/资产库** |
| 资产复用库 | ⭐ | ⭐ | ⭐⭐ Global Assets | **⭐⭐⭐ 个人库+团队库+市场** |
## 6. 关键产品假设
1. **短剧市场足够大**: 2025 年国内短剧市场 500 亿+,AI 化是大势。
2. **创作者愿意为速度付费**: 人工制作 1 集需 3~7 天,AI 只需 1~2 小时,时间价值远超积分费。
3. **质量门槛可接受**: AI 生成质量目前足以应对 60% 的场景(对话、转场、中景),人工补 40% 特写/动作戏。
4. **资产复用是 retention 核心**: 用户做第二个项目时,角色库/场景库复用率 > 50%,形成粘性。
## 7. 不做的事(范围边界)
- ❌ 不做真人视频换脸、不做成人内容(合规风险)
- ❌ 不做实时互动直播(技术栈不同)
- ❌ 不做专业级后期特效(让用户导出到 AE/PR)
- ❌ 第一版不做移动端 App(Web 响应式优先)
# 02 · 前端功能与页面清单
# 02 · 前端功能与页面清单
> 基于 `doc/html` 原型(Vite + React 18 + Shadcn + React Router 7)整理。后端需对齐这里定义的全部功能和数据模型。
## 1. 技术栈速览(前端原型)
- Vite 6 + React 18 + TypeScript + React Router 7
- Tailwind 4 + Shadcn (Radix) + MUI 7(部分)
- Recharts 2(图表)、react-dnd(拖拽)、react-hook-form、sonner
- 状态管理: 纯 React Hooks(生产需引入 Zustand / TanStack Query)
- 当前无真实后端调用,均为 mock 数据
## 2. 路由表
```
/ Dashboard(项目看板)
/new-project 新建项目(上传剧本)
/team 企业级团队管理
/usage 使用统计/积分管理
/models 模型配置(语言/图片/视频)
/settings AI 水印设置(全局)
/assets/* 资产库(角色/场景/道具)
/project/:projectId 项目详情
/style 风格选择
/outline 大纲生成
/episodes 分集生成
/characters 角色生成
/scenes 场景生成
/props 道具生成
/storyboard/:episodeId? 分镜工作台
/video 视频生成与预览
/settings 项目成员与权限
```
## 3. 页面功能详表
### 3.1 Dashboard `/`
- 项目网格(3 列)、统计卡片(总项目/进行中/成员/总集数)
- 搜索:名称模糊 + 日期范围 + 团队成员多选
- 项目卡片: 封面、标题、描述、3 个标签、集数、成员数、更新时间、进度条、状态(outline/storyboard/video)
- **API**: `GET /api/projects`(分页+筛选)、`GET /api/projects/stats`
### 3.2 NewProject `/new-project`
- 拖拽上传 txt/doc/docx/pdf/md(≤20MB)
- 三选项卡: 纵横比(16:9 / 9:16)、分辨率(720p/1080p)、风格(2D日漫等)
- **API**: `POST /api/projects` (multipart)、`POST /api/projects/{id}/parse-script`
### 3.3 StyleSelection `/project/:id/style`
- 6 种预设风格卡片(都市情感/玄幻修仙/甜宠恋爱/古装宫廷/悬疑推理/轻喜剧)
- **API**: `GET /api/styles``PATCH /api/projects/{id}` (style 字段)
### 3.4 OutlineGeneration `/project/:id/outline`
- 显示剧名、类型、梗概、核心主题、4 个故事篇章
- 支持编辑大纲
- **API**: `POST /api/projects/{id}/outline:generate` (异步 jobId)、`GET /api/projects/{id}/outline``PUT /api/projects/{id}/outline`
### 3.5 EpisodeGeneration `/project/:id/episodes`
- 分集网格(5 列),每集标题/时长/梗概
- **API**: `POST /api/projects/{id}/episodes:generate``GET /api/projects/{id}/episodes`
### 3.6 CharacterGeneration `/project/:id/characters`
- 角色 Tab + 场景 Tab + 道具 Tab 切换
- 角色卡片: 头像、类型(女/男主/反派/配角)、性别、年龄、人格、背景、配音音色
- 正面大头照 + 全身三视图拼图
- 服装变体: 同一角色不同服装形态作为独立角色变体生成(如"张三-校服"、"张三-西装"),每个变体有独立四视图
- 操作: 编辑/删除/上传参考图/AI 生成形象/新增/创建服装变体
- **API**: CRUD `/api/projects/{id}/characters` + `POST /api/characters/{cid}/images:generate`
### 3.7 SceneGeneration `/project/:id/scenes`
- 场景卡: 名称、类型(室内/室外)、描述、使用场景、图像
- **API**: CRUD `/api/projects/{id}/scenes` + `POST /api/scenes/{sid}/image:generate`
### 3.8 PropsGeneration `/project/:id/props`
- 道具卡: 名称、分类、描述、使用场景列表、图像
- **API**: CRUD `/api/projects/{id}/props` + `POST /api/props/{pid}/image:generate`
### 3.9 StoryboardWorkspace `/project/:id/storyboard/:episodeId?` ⭐ 核心
- 左侧: 分集选择器(可折叠)
- 中间: 分镜详情编辑
- 分镜信息区:
- 分镜描述(文本)
- 出镜角色(可增删更换,每个角色可选择不同音色)
- 分镜场景(可更换)
- 道具(可增删更换)
- 视频生成区:
- 单一提示词编辑框: 镜头调度、灯光、画风、镜头描述等所有生成指令统一在一个文本框内编写,无其他分散编辑项
- 智能体选择器: 可选择不同的 AI 智能体(如通义万相、Seedance、Kling 等)来生成视频
- 积分预估: 根据所选智能体和视频时长,实时计算预计消耗积分
- 说明: 无独立服装选项,角色的不同服装形态在角色生成阶段作为独立角色变体创建
- 右侧: 预览区(视频/首帧图)
- 底部工具栏: AI 检查、批量操作、生成视频
- 操作: 新增/复制/删除/展开折叠/拖拽排序
- **API**: CRUD `/api/projects/{id}/episodes/{eid}/storyboards`, `PUT .../reorder`, `POST /api/storyboards/{sid}/video:generate`, `POST /api/storyboards/batch:generate`, `POST /api/storyboards/{sid}/ai-check`
### 3.10 VideoGeneration `/project/:id/video`
- 左侧分集列表
- 中间视频播放器 + 分镜时间线
- 右侧分镜缩略图网格(状态: generating/completed/failed/queued/not_started)
- 进度统计
- **API**: `GET /api/projects/{id}/episodes/{eid}/segments``GET /api/storyboards/{sid}/video-status`(SSE 推荐)
### 3.11 ProjectDetail `/project/:id`
- 顶部: 封面、标题、标签、集数、屏幕方向、纵横比、分辨率、小组
- 底部悬浮工具栏: AI 模型选择、积分余额、快速生成按钮
- Tab: 剧本大纲 / 人物小传 / 分集列表
- **API**: `GET /api/projects/{id}` + `GET /api/projects/{id}/full-data`
### 3.12 ProjectSettings `/project/:id/settings`
- 项目团队成员(owner/admin/editor/viewer 四级角色)
- 邀请成员、改角色、移除
- **API**: `GET/POST/PUT/DELETE /api/projects/{id}/members``POST /api/projects/{id}/invitations`
### 3.13 TeamManagement `/team`(企业级)
- 员工 Tab: 列表、编辑、删除、批量导入、邀请
- 小组 Tab: 仙侠/都市/日漫/古装/悬疑 5 个预设,卡片显示成员数和权限
- **API**: `/api/admin/employees``/api/admin/groups`
### 3.14 UsageManagement `/usage`
- 积分卡片(总/已用/剩余/子账户)
- 30 天趋势折线图(实际+预测)
- 使用记录表(类型/项目/用户/小组/积分/时间),支持筛选
- **API**: `/api/usage/stats``/api/usage/daily``/api/usage/records`
### 3.15 AssetsManagement `/assets/*`
- 个人库 / 团队库切换
- 类型筛选(角色/场景/道具)
- 搜索、标签、创建者、风格筛选
- 卡片: 缩略图、名称、标签、创建者、收藏、操作菜单
- 快速复用到当前项目
- **API**: `/api/assets` CRUD、`POST /api/assets/{aid}/clone`
### 3.16 ModelSettings `/models`
- 默认模型选择器: 语言模型 / 图片模型 / 视频模型 三栏独立配置默认模型
- 供应商管理: 按能力类型(语言/图片/视频)分 Tab 显示已配置供应商
- 供应商卡片: 名称、协议标签、Base URL、API Key 状态、已启用模型数
- 模型列表: 每个供应商下展示可用模型,支持搜索、勾选启用
- 添加/编辑供应商弹窗: 名称、协议(OpenAI兼容/DashScope/Gemini/Seedance/Kling)、Base URL、API Key、Secret Key(可灵AK/SK)、手动添加模型 ID / 自动获取模型列表
- 预置供应商: 通义千问(LLM)、通义万相(图片)、OpenAI、Gemini、Seedance(视频)、可灵(视频)
- **API**: `GET/PUT /api/settings/models`, `POST /api/settings/models/providers`, `GET /api/settings/models/fetch`
### 3.17 AIWatermarkSettings `/settings`
- 启用/禁用、文字、位置(5 选)、大小(3 档)、透明度滑块、Logo 上传
- **API**: `GET/PUT /api/settings/watermark`
## 4. 全量数据模型(前端视角)
详见 [06-数据库设计](./06-数据库设计.md),这里仅列核心实体:
```
User / Tenant / Organization
└─ Project (title, description, style, aspectRatio, resolution, status, progress)
├─ Outline (summary, themes[], arcs[])
├─ Episode[] (number, title, description, duration)
│ └─ Storyboard[] (sceneNumber, detailedDescription, location, dialogue[], camera, duration, status, creditCost)
│ └─ ShotAsset[] (type: first_frame|last_frame|video|audio, fileUrl, version)
├─ Character[] (name, role, gender, personality, costume, voice, images{portrait, fullBody, reference})
│ └─ CharacterAppearance[] (version)
├─ Scene[] (name, type, description, image)
├─ Prop[] (name, category, description, scenes[], image)
├─ Member[] (userId, role[owner|admin|editor|viewer])
└─ WatermarkSettings
Asset (shared library, type=char|scene|prop, library=personal|team, tags[])
UsageRecord (type, projectId, userId, credits, timestamp)
BalanceTransaction (type, amount, balanceAfter, taskId)
GenerationTask (type, status, progress, result, errorMessage)
Notification (userId, type, title, read)
```
## 5. 交互与状态管理建议(给前端重构用)
- 引入 **Zustand** 管理 UI 状态(抽屉、选中分镜、上传进度)
- 引入 **TanStack Query** 管理服务器状态(缓存、失效、乐观更新)
- WebSocket/SSE 推送 AI 任务进度,避免轮询
- 分镜工作台使用虚拟列表(react-window)应对 500+ 分镜
- 大文件上传: 分片 + 断点续传(tus-js-client)
# 03 · 三开源项目对比与吸收
# 03 · 三开源项目对比与吸收
## 1. 三个项目一句话画像
| 项目 | 路径 | 定位 | 最大亮点 |
|------|------|------|----------|
| **Toonflow-app** | `Toonflow-app/` | Electron 桌面 AI 短剧工程 | **三层 Agent 协作 + Skill 外化 + 可编程供应商系统** |
| **AIComicBuilder** | `aibuildvideo/AIComicBuilder/` | Next.js 漫剧 Web 全栈 | **角色一致性双轨制 + 12 个提示词插槽化管理 + 首尾帧+视频插值** |
| **waoowaoo** | `waoowaoo/` | Next.js 企业级 AI 视频 SaaS | **BullMQ 任务队列 + 余额冻结计费 + 多租户 + 国际化 + 代码卫士** |
## 2. 功能矩阵
| 维度 | Toonflow | AIComicBuilder | waoowaoo | 吸收到我们项目 |
|------|----------|----------------|----------|----------------|
| 剧本解析 | ✓ 事件图谱驱动 | ✓ 四步导入流水线 | ✓ | **四步导入 + 事件提取**(AICB+Toonflow) |
| 自动分集 | ✓ 前 10 集黄金结构 | ✓ | ✗ | **采用 Toonflow 的付费卡点策略**(10/30/50/70/90%) |
| 角色四视图 | ✗ | ✓ 正面/3/4侧/侧/背 | ✗ | **必做,AICB 方案** |
| 角色参考图注入 | ✓ | ✓ Gemini 参考图 | ✓ | **必做,用通义万相参考图模式** |
| 角色关系图 | ✗ | ✓ characterRelations 表 | ✗ | **必做,影响构图和视线** |
| 分镜智能规划 | ✓ 导演 Agent | ✓ 非常细致 | ✓ | **合并 Toonflow 导演手册 + AICB shot-split** |
| 首尾帧+视频插值 | 部分 | ✓ 主力模式 | ✗ | **主力模式采用** |
| 多模态参考模式 | ✓ | ✓ Veo 3.1 | ✗ | **Pro 用户可选** |
| 三层 Agent | ✓⭐⭐⭐ | ✗ | LangGraph | **采用 Toonflow 三层架构** |
| Agent 记忆 | ✓ 向量+摘要 | ✗ | ✗ | **用 Redis Stack (RediSearch) 实现** |
| Skill 文件外化 | ✓ 159 个 .md | ✓ 12 个提示词 | ✗ | **采用 Markdown Skill 库 + 插槽化编辑** |
| 提示词版本管理 | ✗ | ✓ 完整 | ✗ | **采用 AICB 方案** |
| 可编程供应商 | ✓ VM 沙盒 | ✗ | ✓ 配置化 | **用 Spring 策略模式 + SPI** |
| 资产版本 | ✗ | ✓ shot_assets 表 | ✓ MediaObject 哈希去重 | **合并: 版本表 + 哈希去重** |
| 任务队列 | Socket.IO 简单 | 自研 | ✓ BullMQ + 4 队列 | **采用 RabbitMQ/Kafka + 队列分型** |
| 任务心跳/重试 | 部分 | ✗ | ✓ 完整 | **必做** |
| 多租户 | ✗ | ✗ | ✓ userId 隔离 | **强化: tenantId + Spring Security** |
| 计费系统 | ✗ | ✗ | ✓⭐⭐⭐ 余额冻结 | **直接采用 waoowaoo 方案** |
| 幂等性 | ✗ | ✗ | ✓ idempotencyKey | **必做** |
| 审计日志 | ✗ | ✗ | ✓ 结构化+脱敏 | **必做** |
| 国际化 | ✓ 7 种 | 4 种 | 2 种 | **中英双语起步** |
| 代码卫士 | ✗ | ✗ | ✓ 10+ 守护脚本 | **必做** |
| 测试分层 | ✗ | 部分 | ✓ 单元/集成/系统/回归 | **必做** |
| 可观测性 | ✗ | ✗ | ✓ Bull Board | **Prometheus + Grafana + Spring Actuator** |
| FFmpeg 合成 | ✓ | ✓ 字幕/转场/BGM | ✓ Remotion | **采用 AICB FFmpeg 方案** |
| TTS | 框架 | 未集成 | ✓ FAL/百炼 | **必做: 百炼 Qwen TTS** |
| 口型同步 | ✗ | ✗ | ✓ | **Pro 功能** |
## 3. 各项目核心亮点详述
### 3.1 Toonflow 亮点(架构启发)
**三层 Agent 协作体系** — 这是 Toonflow 最大的技术遗产,值得完整移植:
```
决策层 Agent
├─ 理解用户意图
├─ 任务拆解
├─ 调度执行/监督层
└─ 质量把关(最终通过/驳回)
执行层 SubAgent (6 个)
├─ 故事骨架搭建(三幕 + 付费卡点)
├─ 改编策略制定
├─ 剧本编写
├─ 衍生资产分析
├─ 导演规划
└─ 分镜生成
监督层 Agent
└─ 审核执行层产出,反馈修订
```
**Skill 文件外化**: 159 个 Markdown 文件分别存储提示词、艺术手册、导演约束,运行时动态加载,改动无需重启。
**艺术风格手册**: `art_skills/2D_90s_japanese_anime/` 下有完整的角色/场景/道具约束文档,包含鹅蛋脸、6-6.5 头身、樱桃唇等具体描述 —— 直接复用!
**跨会话记忆**: 基于 ONNX all-MiniLM 做向量化,按项目 ID 隔离,支持 RAG 召回。我们改用 Redis Stack(RediSearch,HNSW + COSINE)+ 通义 embedding,Spring AI `RedisVectorStore` 直连,未来超过 64GB 再迁 Milvus(接口零改动)。
### 3.2 AIComicBuilder 亮点(工程细节)
**角色一致性双轨制**:
1. 视觉轨道: 四视图参考图 → 每次生成首尾帧时作为 reference image 注入
2. 文字轨道: 分镜拆分时强制使用完整角色名,禁止缩写和代词
**12 个提示词 + 插槽化编辑**:
- script_generate / script_parse / script_split
- character_extract / import_character_extract / character_image
- shot_split
- frame_generate_first / frame_generate_last / scene_frame_generate
- video_generate / ref_video_prompt_generate
三层优先级: **项目级覆盖 > 全局级覆盖 > 代码默认值**,且支持**版本历史+一键回滚**
**shot_assets 统一资产表**: 一张表存 first_frame/last_frame/reference/keyframe_video/reference_video 五种类型,配合 assetVersion + isActive 实现版本管理,这个设计直接搬过来。
**四步剧本导入**: 文本抽取 → 角色提取(AI 去重) → 智能分集 → 一键创建,每步可视化、可修改、可回退。
### 3.3 waoowaoo 亮点(SaaS 基础设施)
**余额冻结机制**(核心借鉴):
```
Task 提交 → prepareTaskBilling → 估算成本 → 冻结金额(BalanceFreeze)
Task 执行 → withBilling 包装 → 收集实际用量
Task 完成 → settleTaskBilling → 解冻 + 扣费 + 写 BalanceTransaction
Task 失败 → rollbackTaskBilling → 解冻,不扣费
```
- **原子性**: 冻结即锁定余额,防止超支
- **幂等性**: idempotencyKey 防重复扣费
- **可追溯**: 每笔扣费都有 Transaction 流水
- **自动清理**: 过期冻结自动释放
**MediaObject 统一媒体管理**: 单一媒体表 + sha256 内容哈希 + 40+ 种关系,DRY 原则。
**代码卫士(Guards)**:
- `check:api-handler` — API 签名规范
- `check:prompt-i18n` — 提示词本地化
- 禁止 LLM 直接写在 API(必须走 worker)
- 禁止模型配置硬编码
**测试分层**: 单元 + 集成 + 系统 + 回归 + 并发 + 合约,CI 守护质量。
## 4. 我们的吸收策略
### 4.1 直接搬运(少改)
- AIComicBuilder 的 `shot_assets` 表设计
- AIComicBuilder 的 12 个提示词分类 + 插槽化编辑 + 版本历史
- AIComicBuilder 的四步剧本导入 UX
- AIComicBuilder 的角色四视图生成提示词
- waoowaoo 的 `UserBalance` / `BalanceFreeze` / `BalanceTransaction` 三表结构
- waoowaoo 的 `MediaObject` 统一媒体表
- Toonflow 的 `art_skills/` 艺术手册全套 Markdown 文件
- Toonflow 的故事骨架提示词(三幕 + 付费卡点)
### 4.2 架构移植(Node → Java)
- Toonflow 三层 Agent → Spring AI Alibaba + Tool Use + 多 Chain
- Toonflow 供应商 VM 系统 → Java SPI + Strategy Pattern + `@Component`
- Toonflow Agent Memory → Redis Stack(RediSearch)+ 通义 embedding + Spring AI `RedisVectorStore`
- waoowaoo BullMQ → RabbitMQ/Kafka + Spring AMQP + 多队列(image/video/voice/text)
- waoowaoo NextAuth → Sa-Token(Redis 会话)+ Sa-Token-OAuth2
- waoowaoo Prisma → MyBatis-Plus + Flyway
- waoowaoo next-intl → Spring MessageSource + i18n
### 4.3 自研强化
- **多租户**: 强化到 tenantId + RLS(行级安全)或租户库分库
- **订阅支付**: 集成支付宝/微信/Stripe,waoowaoo 只做了按量
- **权限模型**: RBAC + ABAC,细到分镜级别
- **可观测性**: Prometheus + Grafana + SkyWalking 链路追踪
- **资产市场**: 社区分享+付费购买资产(差异化功能)
## 5. 明确不用的设计
- ❌ Toonflow 的 SQLite — 直接上 MySQL
- ❌ Toonflow 的 Electron 打包 — 我们是 Web SaaS
- ❌ Toonflow 的 VM2 供应商系统 — 安全隐患,改 Java SPI
- ❌ waoowaoo 的 Remotion — 用 FFmpeg 够了,Remotion 是 React 依赖
- ❌ AIComicBuilder 的 SQLite + Drizzle — 同上
- ❌ 三个项目的单机部署 — 我们要分布式
# 04 · 核心业务流程与 AI 流水线
# 04 · 核心业务流程与 AI 流水线
## 1. 端到端业务流程
```
[用户注册/登录]
[创建项目] ─ 上传剧本 + 选择纵横比/分辨率/风格
[剧本解析] ─ 文本抽取 → 角色提取 → 关系推理 → 智能分集
[风格确认] ─ 选择艺术风格手册(2D日漫/国风/3D/水墨...)
[大纲生成] ─ 三幕结构 + 付费卡点 + 篇章划分
[资产建设] ─ 角色四视图 / 场景图 / 道具图(可并行)
[分镜生成] ─ 按集拆分 → 每集 N 个 shot(首尾帧描述 + 对白 + 相机指令)
[首尾帧生成] ─ 注入角色参考图 + 风格约束 + 色板约束
[视频生成] ─ 首尾帧插值或参考图模式(通义万相/可插拔)
[TTS 配音] ─ 按角色音色生成对白音频
[口型同步] ─ 可选(Pro 功能)
[视频合成] ─ FFmpeg 拼接 + 字幕烧录 + BGM 混音 + 转场
[导出/发布] ─ MP4 下载 / 水印处理 / 团队审核
```
## 2. AI 流水线分层
### Stage 1 · 剧本解析流水线
| 步骤 | 输入 | 模型 | 输出 | 存储 |
|------|------|------|------|------|
| 1.1 文本抽取 | 上传文件 | Apache POI / PDFBox | 纯文本 | `projects.script_text` |
| 1.2 事件提取 | 纯文本 | Qwen-Max | 章节 + 事件表 | `novel_events` |
| 1.3 角色提取 | 纯文本 + 事件 | Qwen-Max | 角色卡列表(含去重) | `characters` |
| 1.4 关系推理 | 角色 + 剧本 | Qwen-Max | 关系图 | `character_relations` |
| 1.5 智能分集 | 事件 + 目标集数 | Qwen-Max | 分集方案(三幕 + 卡点) | `episodes` |
**提示词来源**: 借鉴 AIComicBuilder 的 `import_character_extract` + Toonflow 的 `script_execution_skeleton.md`
### Stage 2 · 资产生成流水线
| 步骤 | 输入 | 模型 | 输出 | 特殊约束 |
|------|------|------|------|---------|
| 2.1 角色四视图 | 角色描述 + 风格手册 | 通义万相 | 4 张参考图(正/3/4侧/侧/背) | 单次生成 16:9 画布 |
| 2.2 衍生资产 | 剧本分析 | Qwen-Max | 服装变体/状态变体列表 | 仅状态变化才衍生 |
| 2.3 衍生资产图 | 父资产图 + 变体提示 | 通义万相 | 变体图 | 参考父资产 |
| 2.4 场景图 | 场景描述 + 色板 | 通义万相 | 场景底图 | 支持室内/室外 |
| 2.5 道具图 | 道具描述 | 通义万相 | 道具素材 | 可选 |
**并发**: 10 张/批,RabbitMQ `image.generate` 队列。
### Stage 3 · 分镜生成流水线
| 步骤 | 输入 | 模型 | 输出 |
|------|------|------|------|
| 3.1 导演规划 | 剧本 + 角色 + 关系图 + 风格 | Qwen-Max | 拍摄计划(景别/机位/运动) |
| 3.2 场景分组 | 剧本 + 导演计划 | Qwen-Max | 场景分组 + 每场镜头数 |
| 3.3 镜头拆分 | 场景 + 时长目标 | Qwen-Max | shot 列表(见下) |
| 3.4 监督审核 | shot 列表 | Qwen-Max | 审核报告 + 修订建议 |
**Shot 字段** (借鉴 AIComicBuilder shot-split):
```json
{
"sequence": 1,
"prompt": "张三从树后走出,警惕扫视",
"startFrame": "张三正面从树后探身,左手扶剑柄,夕阳斜照",
"endFrame": "张三走到空地中心,目光锁定前方",
"motionScript": "0-2s: 右肩先露出... 2-4s: 身体侧转...",
"videoScript": "精炼 40-70 字散文提示词",
"duration": 8,
"cameraDirection": "slow zoom in",
"compositionGuide": "rule_of_thirds",
"focalPoint": "张三面部",
"depthOfField": "shallow",
"dialogues": [{"character": "张三", "text": "谁在那里?"}],
"characters": ["张三"],
"transitionIn": "cut",
"transitionOut": "dissolve",
"soundDesign": "夜风、远处蝉鸣",
"musicCue": "紧张低弦乐"
}
```
### Stage 4 · 视觉生成流水线
```
for shot in episode.shots:
first_frame_prompt = build_first_frame(shot, characters, style)
first_frame = call_image_model(
prompt=first_frame_prompt,
reference_images=[char.turnaround_image for char in shot.characters],
size=project.resolution,
ratio=project.aspect_ratio
)
save_asset(shot_id, type='first_frame', url=first_frame, version=v+1)
last_frame_prompt = build_last_frame(shot, first_frame)
last_frame = call_image_model(
prompt=last_frame_prompt,
reference_images=[first_frame] + [char.turnaround]
)
save_asset(shot_id, type='last_frame', ...)
```
**模型选择**(可插拔):
| 模型 | 输入模式 | 质量 | 速度 | 成本 |
|------|---------|------|------|------|
| 通义万相 wanx2.1 | text + ref | ⭐⭐⭐ | 快 | 低 |
| Seedance 2.0 | text + ref + audio | ⭐⭐⭐⭐ | 中 | 中 |
| Veo 3.1 | text + 3 ref | ⭐⭐⭐⭐⭐ | 慢 | 高 |
| Kling v3 | text + ref | ⭐⭐⭐ | 中 | 中 |
### Stage 5 · 视频生成流水线
```
for shot in episode.shots:
video_task = submit_video_task(
model=project.video_model,
first_frame=shot.first_frame_url,
last_frame=shot.last_frame_url,
prompt=shot.videoScript,
duration=shot.duration,
resolution=project.resolution
)
# 异步轮询 5s x 120 次
video_url = poll_until_done(video_task.external_id)
save_asset(shot_id, type='keyframe_video', ...)
```
### Stage 6 · 音频流水线
```
for voice_line in episode.dialogues:
audio = qwen_tts(
text=voice_line.text,
voice=character.voice_id,
emotion=voice_line.emotion,
rate=voice_line.speed
)
save_asset(voice_line_id, type='audio', ...)
```
### Stage 7 · 合成流水线 (FFmpeg)
```
1. 按 shot.sequence 排序所有 keyframe_video
2. xfade 转场拼接(cut/dissolve/fade,默认 0.5s)
3. 生成 SRT 字幕(基于 dialogues 时间比例)
4. 字幕硬编码烧录
5. 混入对白音频(按时间戳)
6. 混入 BGM(volume=0.3)
7. 添加片头标题卡(3s) + 片尾(2s)
8. 输出 /projects/{id}/episodes/{eid}/final.mp4
```
## 3. 三层 Agent 协作(借鉴 Toonflow)
### 3.1 ScriptAgent(剧本 Agent)
```
用户输入 / 剧本上传
[决策层 DecisionAgent]
解析意图 → 拆解任务 → 调度执行/监督
[执行层] 串行调用:
SkeletonExecutor (故事骨架)
↓ 输出到工作区
[监督层 SupervisionAgent] 审核 → 通过/驳回/重做
AdaptationExecutor (改编策略)
[监督层] 审核
ScriptExecutor (剧本编写)
[监督层] 审核
[决策层] 汇总 → 返回用户
```
### 3.2 ProductionAgent(制作 Agent)
```
[决策层]
DeriveAssetsExecutor (衍生资产分析)
GenerateAssetsExecutor (资产图生成, 异步)
DirectorPlanExecutor (导演规划)
↓ [监督]
StoryboardTableExecutor (分镜表)
↓ [监督]
StoryboardPanelExecutor (分镜面板写入)
StoryboardImageExecutor (首尾帧生成, 异步并发)
VideoExecutor (视频生成, 异步并发 + 轮询)
AssemblyExecutor (FFmpeg 合成)
```
### 3.3 实现要点
- 决策层用 Spring AI Alibaba `ChatClient` + `@Tool` 定义子 Agent 为可调用工具
- 执行层每个 Executor 是独立 `@Service`,提示词从 `skills/` 加载
- 监督层可选开启(Free 套餐关闭以省成本)
- 跨 Agent 上下文通过 Redis Stack 向量检索(RediSearch)+ 短期摘要
## 4. 并发与异步策略
| 队列 | 并发 | 超时 | 重试 |
|------|------|------|------|
| `text.generate` | 50 | 60s | 3 次 |
| `image.generate` | 20 | 120s | 3 次 |
| `video.generate` | 10 | 900s | 2 次(昂贵) |
| `voice.tts` | 30 | 30s | 3 次 |
| `video.assemble` | 5 | 1800s | 1 次 |
| `lipsync` | 5 | 600s | 2 次 |
所有任务**必须**通过 RabbitMQ,**禁止**直接在 HTTP 线程里调 AI(借鉴 waoowaoo 代码卫士)。
## 5. 进度推送
**Server-Sent Events (SSE)** 主推送:
```
GET /api/projects/{id}/events (text/event-stream)
event: task.progress
data: {"taskId":"...","type":"video","progress":45,"currentShot":12,"totalShots":50}
event: task.completed
data: {"taskId":"...","resultUrl":"..."}
event: task.failed
data: {"taskId":"...","error":"..."}
```
WebSocket 留给后续实时协作(多人同时编辑分镜)。
This diff is collapsed.
This diff is collapsed.
# 07 · API 接口规范
# 07 · API 接口规范
## 1. 通用约定
- **BaseURL**: `https://api.yaoai.com/v1`
- **格式**: JSON(UTF-8),时间 ISO-8601
- **鉴权**: `Authorization: Bearer <jwt>`,租户从 JWT claims 解析
- **请求 ID**: 每个响应带 `X-Request-Id`
- **分页**: `?page=1&size=20`,响应 `{items[], total, page, size}`
- **错误**: 统一结构
```json
{"error":{"code":"INVALID_PARAM","message":"...","requestId":"..."}}
```
- **幂等**: 写操作支持 `Idempotency-Key`
- **限流**: 返回 `Retry-After`
## 2. 认证
| Method | Path | 说明 |
|--------|------|------|
| POST | `/auth/register` | 注册(email/username + password) |
| POST | `/auth/login` | 登录 {accessToken, refreshToken, user} |
| POST | `/auth/logout` | 注销 |
| POST | `/auth/refresh` | 刷新 token |
| GET | `/auth/me` | 当前用户信息 |
| POST | `/auth/oauth/{provider}/callback` | 第三方登录 |
| POST | `/auth/password/reset-request` | 发送重置邮件 |
| POST | `/auth/password/reset` | token 重置 |
## 3. 租户与成员
| Method | Path | 说明 |
|--------|------|------|
| GET | `/tenants/current` | 当前租户 |
| PATCH | `/tenants/current` | 更新名称/Logo |
| GET | `/tenants/members` | 成员列表 |
| POST | `/tenants/invitations` | 邀请成员 |
## 4. 项目
| Method | Path | 说明 |
|--------|------|------|
| GET | `/projects?page=&search=&status=&from=&to=&members=` | 列表 |
| GET | `/projects/stats` | Dashboard 统计 |
| POST | `/projects` | 创建(multipart: file + meta) |
| GET | `/projects/{id}` | 详情 |
| GET | `/projects/{id}/full-data` | 含大纲/人物/分集聚合 |
| PATCH | `/projects/{id}` | 更新字段 |
| DELETE | `/projects/{id}` | 删除 |
| POST | `/projects/{id}:archive` | 归档 |
### 项目成员
| Method | Path |
|--------|------|
| GET | `/projects/{id}/members` |
| POST | `/projects/{id}/invitations` |
| PATCH | `/projects/{id}/members/{mid}` |
| DELETE | `/projects/{id}/members/{mid}` |
## 5. 剧本解析 (四步导入)
| Method | Path | 说明 |
|--------|------|------|
| POST | `/projects/{id}/import/step1-extract` | 文本抽取 |
| POST | `/projects/{id}/import/step2-characters` | AI 角色提取 |
| GET/PATCH/DELETE | `/projects/{id}/characters/{cid}` | 用户修改角色 |
| POST | `/projects/{id}/import/step3-episodes` | 智能分集 |
| POST | `/projects/{id}/import/step4-confirm` | 一键创建 |
## 6. 风格与大纲
| Method | Path |
|--------|------|
| GET | `/styles` |
| POST | `/projects/{id}:set-style` |
| POST | `/projects/{id}/outline:generate` `taskId` |
| GET | `/projects/{id}/outline` |
| PUT | `/projects/{id}/outline` |
## 7. 分集
| Method | Path |
|--------|------|
| GET | `/projects/{id}/episodes` |
| POST | `/projects/{id}/episodes:generate` |
| PATCH | `/projects/{id}/episodes/{eid}` |
| DELETE | `/projects/{id}/episodes/{eid}` |
## 8. 角色/场景/道具
每种资源一套相同风格:
```
GET /projects/{id}/characters
POST /projects/{id}/characters
GET /projects/{id}/characters/{cid}
PATCH /projects/{id}/characters/{cid}
DELETE /projects/{id}/characters/{cid}
POST /projects/{id}/characters/{cid}:generate-turnaround # 四视图
POST /projects/{id}/characters/{cid}:upload-reference # 参考图
POST /projects/{id}/characters/{cid}/appearances # 新增变体
GET /projects/{id}/characters/{cid}/appearances
```
scenes / props 同构。
## 9. 分镜
| Method | Path | 说明 |
|--------|------|------|
| GET | `/projects/{id}/episodes/{eid}/storyboards` | 列表 |
| POST | `/projects/{id}/episodes/{eid}/storyboards:generate` | AI 批量生成分镜 |
| POST | `/projects/{id}/storyboards` | 手动创建 |
| GET | `/projects/{id}/storyboards/{sid}` | 详情 |
| PATCH | `/projects/{id}/storyboards/{sid}` | 更新 |
| DELETE | `/projects/{id}/storyboards/{sid}` | 删除 |
| POST | `/projects/{id}/storyboards/{sid}:duplicate` | 复制 |
| PUT | `/projects/{id}/episodes/{eid}/storyboards:reorder` | 拖拽重排序 |
| POST | `/projects/{id}/storyboards/{sid}:ai-check` | AI 合规检查 |
### 分镜资产
| Method | Path |
|--------|------|
| GET | `/projects/{id}/storyboards/{sid}/assets?type=FIRST_FRAME&active=true` |
| POST | `/projects/{id}/storyboards/{sid}/assets/first-frame:generate` |
| POST | `/projects/{id}/storyboards/{sid}/assets/last-frame:generate` |
| POST | `/projects/{id}/storyboards/{sid}/assets/video:generate` |
| POST | `/projects/{id}/storyboards/{sid}/assets/audio:generate` |
| PATCH | `/projects/{id}/storyboards/{sid}/assets/{aid}:activate` | 回滚版本 |
### 批量
| Method | Path |
|--------|------|
| POST | `/projects/{id}/episodes/{eid}/storyboards:batch-generate` body={storyboardIds[], targets:[first_frame,last_frame,video]} |
## 10. 视频合成
| Method | Path |
|--------|------|
| POST | `/projects/{id}/episodes/{eid}:assemble` | 合成最终视频(FFmpeg) |
| GET | `/projects/{id}/episodes/{eid}/segments` | 分镜段状态 |
| GET | `/projects/{id}/episodes/{eid}/final` | 最终 MP4 下载 URL |
## 11. 任务与进度
| Method | Path |
|--------|------|
| GET | `/tasks/{taskId}` | 单任务详情 |
| GET | `/tasks?type=&status=&projectId=` | 列表 |
| POST | `/tasks/{taskId}:cancel` | 取消 |
| GET | `/tasks/{taskId}/events` (**SSE**) | 进度事件流 |
| GET | `/projects/{id}/events` (**SSE**) | 项目级事件聚合 |
**SSE 事件格式**:
```
event: task.progress
data: {"taskId":"01HX...","type":"VIDEO_GEN","progress":42,"stage":"polling","message":"Shot 12/50"}
event: task.completed
data: {"taskId":"...","result":{...}}
event: task.failed
data: {"taskId":"...","error":{"code":"...","message":"..."}}
event: credit.consumed
data: {"amount":50,"balance":2340}
```
## 12. 计费
| Method | Path |
|--------|------|
| GET | `/billing/balance` | 当前余额+冻结+已消费 |
| GET | `/billing/transactions?page=&type=` | 流水 |
| POST | `/billing/recharge` body={amount, paymentMethod} → 支付链接 |
| POST | `/billing/subscription` body={plan} → 升级 |
| GET | `/billing/subscription` | 当前订阅 |
| POST | `/billing/subscription:cancel` |
| GET | `/usage/stats?from=&to=` | 积分统计 |
| GET | `/usage/daily?from=&to=` | 30 天趋势 |
| GET | `/usage/records?...` | 明细 |
## 13. 资产库
| Method | Path |
|--------|------|
| GET | `/assets?library=personal&type=character&tags=&search=` |
| POST | `/assets` | 上传 |
| GET | `/assets/{aid}` |
| PATCH | `/assets/{aid}` |
| DELETE | `/assets/{aid}` |
| POST | `/assets/{aid}:favorite` |
| POST | `/assets/{aid}:clone-to-project` body={projectId} |
## 14. 设置与提示词
| Method | Path |
|--------|------|
| GET/PUT | `/settings/watermark` |
| GET | `/prompt-templates?scope=&key=` |
| POST | `/prompt-templates` |
| PATCH | `/prompt-templates/{id}` |
| GET | `/prompt-templates/{id}/versions` |
| POST | `/prompt-templates/{id}:rollback?version=` |
| GET/POST | `/prompt-presets` |
## 15. 企业管理(`/admin/*`)
需要 `ADMIN``OWNER` 角色:
| Method | Path |
|--------|------|
| GET | `/admin/employees?search=&status=` |
| POST | `/admin/employees` |
| PATCH | `/admin/employees/{id}` |
| DELETE | `/admin/employees/{id}` |
| POST | `/admin/employees:bulk-import` (CSV) |
| GET | `/admin/groups` |
| POST | `/admin/groups` |
| PATCH | `/admin/groups/{id}` |
| GET | `/admin/audit-logs?...` |
## 16. 系统
| Method | Path |
|--------|------|
| GET | `/system/boot-id` | 健康检查 |
| GET | `/system/version` | 版本 |
| GET | `/system/notifications` | 全局公告 |
## 17. OpenAPI 规范
- 使用 `springdoc-openapi` 自动生成 `/v3/api-docs` 和 Swagger UI
- 所有 DTO 用 Bean Validation 注解(`@NotNull`/`@Size`/`@Email`)
- Controller 强制使用 `ResponseEntity<ApiResponse<T>>` 统一包装
- 错误码集中维护在 `ErrorCode` 枚举
This diff is collapsed.
This diff is collapsed.
# 10 · 部署与运维
# 10 · 部署与运维
## 1. 环境矩阵
| 环境 | 用途 | 规模 |
|------|------|------|
| **local** | 开发者本地 | Docker Compose 一键起 |
| **dev** | 集成测试、PR 预览 | 单节点 K8s / docker-compose |
| **staging** | 预发布、压测 | 2 节点 K8s,真实数据缩减版 |
| **prod** | 生产 | 多可用区 K8s + 完整灾备 |
## 2. Docker Compose(本地开发)
```yaml
# docker-compose.yml
version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: yaoai123
MYSQL_DATABASE: yaoai
ports: ["13306:3306"]
volumes: ["mysql_data:/var/lib/mysql"]
redis:
# Redis Stack = Redis + RediSearch(向量)+ RedisJSON + RedisInsight
image: redis/redis-stack:7.2.0-v10
command: redis-stack-server --appendonly yes
ports: ["16379:6379", "18001:8001"] # 8001 是 RedisInsight 控制台
volumes: ["redis_data:/data"]
rabbitmq:
image: rabbitmq:3.13-management
ports: ["5672:5672", "15672:15672"]
volumes: ["rabbit_data:/var/lib/rabbitmq"]
# 对象存储:统一使用阿里云 OSS / 火山引擎 TOS,不在 docker-compose 内起
# 本地开发直连开发环境 OSS Bucket(yaoai-dev),通过 AK/SK 读写
# 未来若需完全离线开发,再按 TODO 加回 MinIO(S3 兼容)
# 注:Elasticsearch 暂不引入,MVP 用 MySQL FULLTEXT;M8 团队协作/资产库阶段再评估
api:
build: .
depends_on: [mysql, redis, rabbitmq]
environment:
SPRING_PROFILES_ACTIVE: local
MYSQL_HOST: mysql
REDIS_HOST: redis # 同一 Redis Stack 实例同时承载缓存 + 向量
RABBIT_HOST: rabbitmq
YAOAI_STORAGE_PROVIDER: oss # oss | tos(MinIO 作为未来 TODO)
YAOAI_VECTOR_PROVIDER: redis
OSS_ENDPOINT: ${OSS_ENDPOINT} # 生产:oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY: ${OSS_ACCESS_KEY}
OSS_SECRET_KEY: ${OSS_SECRET_KEY}
QWEN_API_KEY: ${QWEN_API_KEY}
ports: ["18080:8080"]
command: java -jar app.jar --spring.profiles.active=local,api
worker:
build: .
depends_on: [mysql, redis, rabbitmq]
environment: (同上)
command: java -jar app.jar --spring.profiles.active=local,worker
deploy:
replicas: 2
volumes:
mysql_data: {}
redis_data: {}
rabbit_data: {}
```
## 3. Dockerfile(多阶段构建)
```dockerfile
# 构建阶段
FROM eclipse-temurin:17-jdk-alpine AS builder
WORKDIR /build
COPY . .
RUN ./mvnw -B -DskipTests package -pl yaoai-bootstrap -am
# 运行阶段
FROM eclipse-temurin:17-jre-alpine
# 安装 FFmpeg(Assemble Worker 需要)
RUN apk add --no-cache ffmpeg
WORKDIR /app
COPY --from=builder /build/yaoai-bootstrap/target/yaoai-bootstrap-*.jar app.jar
COPY skills/ ./skills/
EXPOSE 8080
ENTRYPOINT ["java","-XX:+UseContainerSupport","-jar","app.jar"]
```
## 4. Kubernetes 部署(生产)
### 4.1 部署单元
```
Deployment: yaoai-api replicas=3 (HPA by CPU 70%)
Deployment: yaoai-worker-text replicas=2 (HPA by queue depth)
Deployment: yaoai-worker-image replicas=3 (HPA by queue depth)
Deployment: yaoai-worker-video replicas=2 (HPA by queue depth, GPU)
Deployment: yaoai-worker-voice replicas=2
Deployment: yaoai-worker-assemble replicas=1 (FFmpeg,CPU 密集)
CronJob: yaoai-scheduler (定时任务,单实例)
StatefulSet: mysql (或用云 RDS)
StatefulSet: redis-stack (或云 Tair / Redis Enterprise 带 RediSearch 模块)
StatefulSet: rabbitmq 集群
对象存储: 阿里云 OSS / 火山引擎 TOS(托管,不在 K8s 内)
Ingress: yaoai.com / api.yaoai.com
```
### 4.2 HPA 按队列深度扩缩
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: yaoai-worker-video
spec:
minReplicas: 2
maxReplicas: 20
metrics:
- type: External
external:
metric:
name: rabbitmq_queue_depth
selector:
matchLabels:
queue: video.generate
target:
type: AverageValue
averageValue: "5" # 每 5 个任务扩 1 个 Pod
```
### 4.3 Secret 管理
- 用 ExternalSecrets + HashiCorp Vault 或 AWS Secrets Manager
- API Key、DB 密码、Sa-Token `token-name`/加密盐绝不进 Git
## 5. CI/CD
```
[ Push to main ]
GitHub Actions
├─ mvn test(单元 + 集成 with Testcontainers)
├─ ESLint / SonarQube 扫描
├─ Docker buildx → push to registry
├─ helm upgrade yaoai-staging
├─ 冒烟测试(Playwright)
[ Manual Approve ]
helm upgrade yaoai-prod (蓝绿 / 滚动)
```
## 6. 数据迁移
- **Flyway** 管理 schema,强制版本化
- 每次上线先跑 `V{n}__description.sql` 再发 app(新字段 + 默认值兼容旧代码)
- 大表 DDL 用 gh-ost / pt-online-schema-change 避免锁表
- 回滚方案:每个版本都有 `Undo_V{n}.sql`(可选)
## 7. 监控与告警
### 7.1 关键指标
| 类别 | 指标 | 告警阈值 |
|------|------|----------|
| API | p99 延迟 | > 2s |
| API | 5xx 率 | > 1% |
| MQ | 队列积压 | video > 50 / image > 100 |
| Worker | 失败率 | > 5% / 10min |
| DB | 慢查询 | > 1s 超过 10 条 /分钟 |
| AI | Provider 错误率 | > 10% |
| 业务 | 余额扣减异常 | 任何负数 |
| 业务 | 任务 stuck | processing 超过 30min |
### 7.2 告警通道
- 即时: 钉钉/企业微信 机器人
- 持续: PagerDuty / Opsgenie
- 日报: 每日 09:00 推送昨日核心指标
## 8. 备份与灾备
| 数据 | 策略 |
|------|------|
| MySQL | 每日全量 + binlog 增量,异地冷存 30 天 |
| Redis Stack(缓存 + 向量) | AOF 持久化 + 每日 RDB 快照;向量索引实例与缓存实例分开部署,向量实例优先保证持久化 |
| RabbitMQ | 队列持久化 + 定期导出定义 |
| OSS / TOS | 版本化 Bucket + 跨区域复制 + 生命周期归档 |
| 配置 | Git 版本化,不可变 |
RTO < 4h,RPO < 15min。
## 9. 安全基线
- [ ] HTTPS 全站(Let's Encrypt)
- [ ] WAF(Aliyun / Cloudflare)
- [ ] SQL 注入防护(参数化查询,禁 `@Query` 字符串拼接)
- [ ] XSS 防护(DTO 输出 escape)
- [ ] CSRF(Sa-Token 前后端分离通过请求头传 Token,天然免疫)
- [ ] 依赖扫描(Dependabot + Snyk)
- [ ] 敏感日志脱敏(统一 `LogRedactor`)
- [ ] API Key 加密存储(Jasypt + 主密钥放 KMS)
- [ ] 定期渗透测试(半年一次)
- [ ] 漏洞奖励计划(产品稳定后)
## 10. 运营手册(Runbook)片段
### 10.1 视频生成任务卡住
1. `kubectl logs -l app=worker-video --tail=200`
2.`tasks where status='PROCESSING' and heartbeat_at < NOW() - INTERVAL 5 MINUTE`
3. 查第三方 Provider 状态页
4. 临时措施: 手动 `markDead` → 重入队
5. 根因: 如果是 Provider 故障,切 fallback provider
### 10.2 余额异常
1.`user_balances``balance``frozen_amount` 为负的行
2. 对照 `balance_transactions` 重算
3. 临时措施: 冻结账户,人工补偿
4. 根因: 定位是否有竞态(没走乐观锁)
### 10.3 Worker OOM
1. `kubectl top pod`
2. 检查是否在单任务下载 1GB+ 视频到堆
3. 修复: 流式下载直接写 OSS,不全量加载
# 11 · 实施路线图与里程碑
# 11 · 实施路线图与里程碑
## 0. 总原则
- **单人/小团队起步**: 前 2 个月目标是把 MVP 跑通,而不是造全部功能
- **端到端优先**: 每个里程碑都要有可演示的用户价值,而不是只补基础设施
- **吸收 > 重造**: 有现成开源代码/提示词/skill 的地方直接搬,不要重新发明
- **前端保留原型**: `doc/html` 作为 UI 底稿,只做必要的接入层改造(加 TanStack Query + Zustand),不重写 UI
## 1. 里程碑总览
| 阶段 | 周期 | 目标 | 产出 |
|------|------|------|------|
| **M0 · 基建** | 第 1~2 周 | 工程骨架跑通 | 空 API + Swagger + Docker |
| **M1 · 认证与项目 CRUD** | 第 3~4 周 | 用户能登录、建项目 | Dashboard + NewProject 可用 |
| **M2 · 剧本解析流水线** | 第 5~7 周 | 上传剧本 → 出角色/分集 | 四步导入 UI + 异步任务 |
| **M3 · 资产生成** | 第 8~10 周 | 角色四视图 + 场景 + 道具 | Character/Scene/Props 可用 |
| **M4 · 分镜工作台** | 第 11~13 周 | AI 生成分镜 + 手动编辑 | StoryboardWorkspace 可用 |
| **M5 · 视觉生成** | 第 14~16 周 | 首尾帧 + 视频生成 | 单镜头视频可生成 |
| **M6 · 合成与导出** | 第 17~18 周 | FFmpeg 拼成完整集 | 可下载完整 MP4 |
| **M7 · 计费与 SaaS** | 第 19~21 周 | 余额、订阅、支付 | 真正 SaaS 化 |
| **M8 · 团队协作** | 第 22~24 周 | 多人协作、资产库、权限 | 企业功能 |
| **M9 · 上线与优化** | 第 25~26 周 | 压测、监控、灰度 | 生产发布 |
**总计约 6 个月**(1~2 名后端 + 1 名前端,MVP 可提前到 3 个月但砍 M7~M9)。
## 2. 阶段详解
### M0 · 基建(第 1~2 周)
**交付清单**:
- [ ] Maven 多模块骨架(按 05-后端架构设计.md 的模块划分)
- [ ] Spring Boot 3 + Spring AI Alibaba 依赖跑通
- [ ] Flyway 初始化 + 空表结构
- [ ] Docker Compose 一键启动(mysql + redis-stack + rabbitmq;对象存储直连开发环境 OSS Bucket)
- [ ] Actuator + Prometheus endpoint
- [ ] Swagger / OpenAPI 自动文档
- [ ] 统一异常处理 + ApiResponse<T> 包装
- [ ] Logback JSON + MDC(requestId)
- [ ] GitHub Actions: PR 跑 test + build
- [ ] 前端 `doc/html` 接入 TanStack Query + Zustand + API client
**完成标准**: `docker compose up` 后能访问 Swagger UI 并看到 1 个 `/ping` 接口。
### M1 · 认证与项目 CRUD(第 3~4 周)
- [ ] Sa-Token 1.39+(`sa-token-spring-boot3-starter` + `sa-token-redis-jackson`),Token 存 Redis
- [ ] `/auth/*` 全套(注册/登录/刷新/登出/me)
- [ ] IP 登录限流(Bucket4j + Redis)
- [ ] Tenant 自动创建(注册即建个人租户)
- [ ] `TenantContextFilter` + MyBatis-Plus `TenantLineInnerInterceptor`
- [ ] Projects 表 + CRUD API
- [ ] `/projects/stats` 统计接口
- [ ] 前端 Dashboard + NewProject 真实接入(去 mock)
- [ ] 前端 Auth Guard
**完成标准**: 用户注册→登录→创建一个空项目→在 Dashboard 看到它。
### M2 · 剧本解析流水线(第 5~7 周)
- [ ] 文件上传 + 阿里云 OSS / 火山引擎 TOS 接入(`StorageProvider` 抽象接口,MinIO 作为未来 TODO 备选)+ MediaObject 表
- [ ] 文本抽取(POI + PDFBox,支持 txt/doc/docx/pdf/md)
- [ ] RabbitMQ 接入 + Task 表 + TaskService + 通用 Worker 模板
- [ ] Spring AI Alibaba 通义千问接入(chat/embedding)
- [ ] `SkillLoader` + 从 Toonflow 拷贝的 `script_execution_skeleton.md`
- [ ] 实现 Executor: `ScriptParseExecutor` + `CharacterExtractExecutor` + `EpisodeSplitExecutor`
- [ ] 四步导入 API + SSE 进度
- [ ] 前端 NewProject 的四步向导(借鉴 AIComicBuilder UX)
- [ ] Character/Scene/Props 表 + 基础 CRUD
**完成标准**: 上传一篇 5000 字的小说,能自动出角色列表和分集列表,全程 SSE 推进度。
### M3 · 资产生成(第 8~10 周)
- [ ] `AiProvider` 接口 + `WanxImageProvider`(通义万相文生图)
- [ ] 兜底 Provider: OpenAI DALL-E 或 Gemini Imagen(可配置)
- [ ] 从 Toonflow 拷 `art_skills/2d_anime/` 全套艺术手册
- [ ] `CharacterTurnaroundExecutor`(四视图,借鉴 AIComicBuilder)
- [ ] `SceneImageExecutor` / `PropImageExecutor`
- [ ] `shot_assets` 表 + 资产版本管理
- [ ] 前端 CharacterGeneration / SceneGeneration / PropsGeneration 页接入
- [ ] 参考图上传 + 注入到生成请求
- [ ] 初级计费(SOFT 模式)
**完成标准**: 项目里每个角色都能一键生成四视图,前端能看到并支持重新生成。
### M4 · 分镜工作台(第 11~13 周)
- [ ] `Storyboard` 表完整字段(参考 AIComicBuilder shot 结构)
- [ ] `DirectorPlanExecutor`(借鉴 Toonflow 导演手册)
- [ ] `StoryboardSplitExecutor`(借鉴 AIComicBuilder shot-split.ts)
- [ ] `SupervisionAgent` 雏形(可选开关)
- [ ] Storyboard CRUD + 批量生成 + 排序 + 复制
- [ ] 前端 StoryboardWorkspace 完整接入
- [ ] 拖拽排序 API(批量 sequence 更新)
- [ ] AI 合规检查 API(调 LLM 做敏感词+内容审核)
**完成标准**: 某集的全部分镜可一键 AI 生成,然后用户可手动编辑每个镜头。
### M5 · 视觉生成(第 14~16 周)
- [ ] `FrameGenerateExecutor`(首尾帧,参考图注入)
- [ ] `VideoProvider` 接口 + `WanxVideoProvider`
- [ ] 备选: SeedanceProvider / KlingProvider
- [ ] `VideoGenerateExecutor`(异步提交 + 轮询)
- [ ] 任务心跳机制上线
- [ ] 失败重试 + Provider fallback
- [ ] 前端 StoryboardWorkspace 右侧预览接入
- [ ] 前端 VideoGeneration 页面接入(分镜网格状态)
**完成标准**: 选中一个分镜,点"生成视频",10 分钟内能看到 5~8 秒短视频,参考图约束生效(角色相对一致)。
### M6 · 合成与导出(第 17~18 周)
- [ ] FFmpeg 调度封装(`FfmpegService`)
- [ ] `AssembleExecutor`:拼接 + 转场 + 字幕 + BGM
- [ ] TTS 接入(Qwen-TTS):`VoiceTtsExecutor`
- [ ] 全集合成 API + 下载链接签名
- [ ] 水印烧录(全局设置)
**完成标准**: 一集的所有分镜生成完成后,点"合成",得到一个带字幕、配音、BGM 的完整 MP4。
### M7 · 计费与 SaaS(第 19~21 周)
- [ ] `user_balances` / `balance_freezes` / `balance_transactions` 三表上线
- [ ] `BillingService`(prepare/settle/rollback)集成到所有任务
- [ ] BILLING_MODE=STRICT 切换
- [ ] 订阅表 + 套餐定义
- [ ] 支付宝 + 微信支付接入(Webhook)
- [ ] Stripe(海外)
- [ ] 充值页面 + 订阅页面
- [ ] `/usage/*` 统计 API
- [ ] 前端 UsageManagement 接入
- [ ] 定时任务:过期冻结清理、订阅续费
**完成标准**: 用户能充值、消费会扣积分、余额不足被拦截、流水可查。
### M8 · 团队协作与资产库(第 22~24 周)
- [ ] 项目成员邀请 + RBAC 4 角色
- [ ] 企业员工/小组(`/team` 页)
- [ ] 资产库:个人 + 团队(`global_assets` 表)
- [ ] 资产收藏、复用到项目
- [ ] 通知系统(`notifications` 表 + WebPush 可选)
- [ ] 审计日志(`audit_logs` + `@Audited` 切面)
- [ ] 前端 TeamManagement / AssetsManagement 接入
- [ ] 提示词模板的项目级覆盖 UI
**完成标准**: 团队成员可被邀请到项目,权限隔离正确,资产能跨项目复用。
### M9 · 上线与优化(第 25~26 周)
- [ ] 压测(JMeter/k6)
- [ ] 性能优化(慢 SQL、N+1、缓存)
- [ ] Prometheus + Grafana 监控面板
- [ ] SkyWalking 链路追踪
- [ ] 告警接入钉钉
- [ ] K8s Helm chart
- [ ] 灰度发布方案
- [ ] 运营后台基础(用户/订单查询)
- [ ] 用户文档、API 文档
- [ ] 上线!
## 3. 风险与缓解
| 风险 | 影响 | 缓解 |
|------|------|------|
| 通义万相/视频模型配额不足 | 阻塞 M5 | 并行接入 2 家 Provider,签商务合同 |
| 角色一致性效果不达标 | 用户体验差 | 四视图 + 强制命名 + 必要时引入 LoRA 微调 |
| 视频生成单价过高 | 毛利低 | 精确计费、短视频优先、非关键镜头降级 |
| FFmpeg 性能瓶颈 | 合成慢 | Worker 独立扩容 + GPU 编码 |
| 多租户数据泄漏 | 法律风险 | 代码卫士强制 Filter + 自动化测试 |
| Agent 幻觉导致低质产出 | 用户不满 | 监督层 + 用户最终审核 |
## 4. 后续(v2+)想做的事
- 移动端 App(React Native)
- 实时协作编辑分镜(CRDT / Yjs + WebSocket)
- 模型市场(用户上传 LoRA)
- 资产市场(付费分享角色/场景)
- AI 审核合规自动化(全量扫敏感内容)
- 多语言配音(英日韩)
- 真人参考视频驱动(动作迁移)
- 导出到剪映/PR 的 XML 工程
- 开放 API(面向开发者)
# 12 · 前端原型复盘与流程验证
# 12 · 前端原型复盘与流程验证
> 最近一次复盘日期: 2026-04-16
## 1. 端到端用户流程
```
Dashboard (/)
│ 点击"新建项目"
NewProject (/new-project)
│ 上传剧本(.txt/.doc/.docx/.pdf/.md) + 选纵横比/分辨率/风格
│ 点击"创建项目"
StyleSelection (/project/:id/style)
│ 选择6种艺术风格之一
│ 点击"继续"
OutlineGeneration (/project/:id/outline)
│ AI生成大纲(三幕结构+篇章) → 可编辑
│ 点击"继续"
EpisodeGeneration (/project/:id/episodes)
│ AI生成分集列表(80集网格)
│ 点击"继续生成角色设定"
CharacterGeneration (/project/:id/characters)
│ 角色卡 + 四视图 + 服装变体(独立角色形态)
│ 可切Tab到场景/道具
│ 点击"继续生成场景设定"
SceneGeneration (/project/:id/scenes)
│ 场景卡 + AI生成场景图
│ 点击"继续生成道具设定"
PropsGeneration (/project/:id/props)
│ 道具卡 + AI生成道具图
│ 点击"生成分镜"
StoryboardWorkspace (/project/:id/storyboard/:episodeId)
│ 左侧: 分集选择器
│ 中间: 分镜列表(分镜信息区 + 视频生成区)
│ 分镜信息区: 描述、出镜角色(增删+音色)、场景(更换)、道具(增删)
│ 视频生成区: 单一提示词编辑框 + 智能体选择器 + 积分预估
│ 右侧: 视频预览
│ 通过顶部导航Tab切换到视频页
VideoGeneration (/project/:id/video)
│ 视频播放器 + 分镜时间线 + 状态网格
│ 可返回分镜编辑
ProjectDetail (/project/:id)
│ 总览: 大纲/角色/分集/分镜/视频各Tab
```
## 2. 页面清单(17个)
| # | 页面 | 路由 | UI完整 | 流程衔接 | 备注 |
|---|---|---|---|---|---|
| 1 | Dashboard | `/` | ✅ | ✅→NewProject/ProjectDetail | 项目网格+统计+搜索 |
| 2 | NewProject | `/new-project` | ✅ | ✅→Style | 上传剧本+三选项 |
| 3 | StyleSelection | `/project/:id/style` | ✅ | ✅→Outline | 6种预设风格 |
| 4 | OutlineGeneration | `/project/:id/outline` | ✅ | ✅→Episodes | AI生成+可编辑 |
| 5 | EpisodeGeneration | `/project/:id/episodes` | ✅ | ✅→Characters | 分集网格 |
| 6 | CharacterGeneration | `/project/:id/characters` | ✅ | ✅→Scenes | 角色+服装变体 |
| 7 | SceneGeneration | `/project/:id/scenes` | ✅ | ✅→Props | 场景卡+AI生成 |
| 8 | PropsGeneration | `/project/:id/props` | ✅ | ✅→Storyboard | 道具卡+AI生成 |
| 9 | StoryboardWorkspace | `/project/:id/storyboard/:eid` | ✅ | ✅ TopNav | 分镜编辑+视频生成 |
| 10 | VideoGeneration | `/project/:id/video` | ✅ | ✅ 可返回 | 播放器+时间线 |
| 11 | ProjectDetail | `/project/:id` | ✅ | ✅ 多Tab | 项目总览 |
| 12 | ProjectSettings | `/project/:id/settings` | ✅ | ✅ | 团队成员+权限 |
| 13 | TeamManagement | `/team` | ✅ | ✅ | 企业团队管理 |
| 14 | UsageManagement | `/usage` | ✅ | ✅ | 用量统计+图表 |
| 15 | AssetsManagement | `/assets/*` | ✅ | ✅ | 个人/团队资产库 |
| 16 | ModelSettings | `/models` | ✅ | ✅ | 语言/图片/视频模型配置 |
| 17 | AIWatermarkSettings | `/settings` | ✅ | ✅ | 水印设置+预览 |
## 3. 设计文档对齐度
| 设计文档要求 | 原型实现 | 状态 |
|---|---|---|
| 单一提示词编辑框 | StoryboardWorkspace detailedDescription textarea | ✅ |
| 智能体选择器(通义万相/Seedance/Kling) | 底部工具栏 select | ✅ |
| 积分预估(按智能体+时长) | 生成按钮显示消耗积分 | ✅ |
| 出镜角色增删+音色选择 | 分镜信息区 characters + voice select | ✅ |
| 场景可更换 | 场景卡片 + "更换"按钮 | ✅ |
| 道具可增删 | 道具网格 + "添加"按钮 | ✅ |
| 服装变体机制 | CharacterGeneration 变体列表+创建弹窗 | ✅ |
| 角色变体格式("林若曦·白色晚礼服") | Storyboard 角色下拉已联动 | ✅ |
| 模型配置页(三类独立) | ModelSettings 语言/图片/视频 | ✅ |
| 文件上传支持 .md | NewProject accept 已添加 | ✅ |
| 模型名使用真实名称 | 全部替换为通义万相/Seedance/Kling | ✅ |
| 登录/注册页 | 缺失(M1阶段做) | ⏳ |
| 充值/订阅页 | 缺失(M7阶段做) | ⏳ |
## 4. 已修复问题
| 问题 | 修复内容 |
|---|---|
| PropsGeneration "生成分镜"按钮无导航 | 添加 navigate 跳转 storyboard/1 |
| NewProject 不支持 .md 格式 | accept 添加 .md |
| 多页面模型名使用假数据 | SceneGeneration/PropsGeneration/ProjectDetail 替换为真实模型名 |
| 角色页无服装变体机制 | 新增 CharacterVariant 数据结构+创建变体UI+管理交互 |
| 分镜页角色选择无变体联动 | 角色下拉改为"角色·变体"格式 |
| 缺少模型配置页 | 新增 ModelSettings 页(参考 AIComicBuilder) |
## 5. 技术债务(原型阶段可接受)
- 所有数据为 mock,无真实 API 调用(M0 接入)
- 拖拽排序仅引入 react-dnd 依赖,未实现拖拽逻辑
- 无表单验证提示(仅部分按钮 disabled)
- 无错误边界/Loading skeleton
- 状态管理为纯 useState,生产需迁移 Zustand + TanStack Query
- 无登录/注册页(M1)、无充值/订阅页(M7)
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
---
---
今天完成(M0)
- 16模块 Maven 多模块项目从零搭建完毕
- 核心模块:common / api / bootstrap,含统一响应体、全局异常、Swagger、请求ID过滤器
- Flyway 数据库迁移(tenants / users / tenant_members)
- docker-compose:MySQL / Redis / RabbitMQ
- Dockerfile 多阶段构建
- Git 初始化 + 2 个 commit
- 前端:TanStack Query + Zustand + Axios 骨架接入
- Swagger UI 验证通过,/ping 正常响应
---
---
M1 完成情况总结:
后端:
- yaoai-domain:Tenant / User / TenantMember 实体 + MyBatis-Plus Mapper
- yaoai-security:TenantContext(ThreadLocal)、Sa-Token 拦截器(保护除 /auth/**
以外的所有路由)、AuthService(注册/登录/登出/me)
- yaoai-api:AuthController(4 个端点)、更新 GlobalExceptionHandler(处理 401/403)
- yaoai-bootstrap:Redis + MyBatis-Plus + Sa-Token 配置写入 application.yml、DevDataInitializer(local profile
首次启动创建 admin@yaoai.com / Admin@123)
前端:
- src/lib/api/auth.ts:API 调用层
- src/hooks/useAuth.ts:TanStack Query mutations + Zustand store 联动
- src/app/pages/LoginPage.tsx / RegisterPage.tsx:登录/注册表单
- src/app/components/PrivateRoute.tsx:路由保护
- routes.tsx:添加 /login、/register,所有已有路由被 PrivateRoute 包裹
验证方式:
1. 启动 Docker:docker compose up -d
2. 运行(须用 Java 17):JAVA_HOME="D:/Program Files/Java/jdk-17" mvn package -pl yaoai-bootstrap -am -DskipTests &&
java -jar yaoai-bootstrap/target/yaoai-bootstrap-0.1.0-SNAPSHOT.jar
3. 测试:POST /auth/login {"email":"admin@yaoai.com","password":"Admin@123"} → 收到 token
4. 用 token 访问 GET /auth/me → 返回用户信息
5. 不带 token 访问 /ping → 200(开放);不带 token 访问其他接口 → 401
This diff is collapsed.
This diff is collapsed.
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
# AI短剧管理平台
This is a code bundle for AI短剧管理平台. The original project is available at https://www.figma.com/design/Xkm19s8QQkeNpWCXLOlfgn/AI%E7%9F%AD%E5%89%A7%E7%AE%A1%E7%90%86%E5%B9%B3%E5%8F%B0.
## Running the code
Run `npm i` to install the dependencies.
Run `npm run dev` to start the development server.
\ No newline at end of file
/* KEEP_IN_SYNC(fullscreen/resources/figmake/shadcn/globals.css) */
:root {
--font-size: 16px;
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #030213;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
--muted: #ececf0;
--muted-foreground: #717182;
--accent: #e9ebef;
--accent-foreground: #030213;
--destructive: #d4183d;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.1);
--input: transparent;
--input-background: #f3f3f5;
--switch-background: #cbced4;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #030213;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
**Add your own guidelines here**
<!--
System Guidelines
Use this file to provide the AI with rules and guidelines you want it to follow.
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
# General guidelines
Any general rules you want the AI to follow.
For example:
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
* Refactor code as you go to keep code clean
* Keep file sizes small and put helper functions and components in their own files.
--------------
# Design system guidelines
Rules for how the AI should make generations look like your company's design system
Additionally, if you select a design system to use in the prompt box, you can reference
your design system's components, tokens, variables and components.
For example:
* Use a base font-size of 14px
* Date formats should always be in the format “Jun 10”
* The bottom toolbar should only ever have a maximum of 4 items
* Never use the floating action button with the bottom toolbar
* Chips should always come in sets of 3 or more
* Don't use a dropdown if there are 2 or fewer options
You can also create sub sections and add more specific details
For example:
## Button
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
users through the application. It provides visual feedback and clear affordances to enhance user experience.
### Usage
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
### Variants
* Primary Button
* Purpose : Used for the main action in a section or page
* Visual Style : Bold, filled with the primary brand color
* Usage : One primary button per section to guide users toward the most important action
* Secondary Button
* Purpose : Used for alternative or supporting actions
* Visual Style : Outlined with the primary color, transparent background
* Usage : Can appear alongside a primary button for less important actions
* Tertiary Button
* Purpose : Used for the least important actions
* Visual Style : Text-only with no border, using primary color
* Usage : For actions that should be available but not emphasized
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI短剧管理平台</title>
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "@figma/my-make-file",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@mui/icons-material": "7.3.5",
"@mui/material": "7.3.5",
"@popperjs/core": "2.11.8",
"@radix-ui/react-accordion": "1.2.3",
"@radix-ui/react-alert-dialog": "1.1.6",
"@radix-ui/react-aspect-ratio": "1.1.2",
"@radix-ui/react-avatar": "1.1.3",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-collapsible": "1.1.3",
"@radix-ui/react-context-menu": "2.2.6",
"@radix-ui/react-dialog": "1.1.6",
"@radix-ui/react-dropdown-menu": "2.1.6",
"@radix-ui/react-hover-card": "1.1.6",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-menubar": "1.1.6",
"@radix-ui/react-navigation-menu": "1.2.5",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-progress": "1.1.2",
"@radix-ui/react-radio-group": "1.2.3",
"@radix-ui/react-scroll-area": "1.2.3",
"@radix-ui/react-select": "2.1.6",
"@radix-ui/react-separator": "1.1.2",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-switch": "1.1.3",
"@radix-ui/react-tabs": "1.1.3",
"@radix-ui/react-toggle-group": "1.1.2",
"@radix-ui/react-toggle": "1.1.2",
"@radix-ui/react-tooltip": "1.1.8",
"@tanstack/react-query": "5.80.7",
"@tanstack/react-query-devtools": "5.80.7",
"axios": "1.7.9",
"zustand": "5.0.3",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "3.6.0",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "0.487.0",
"motion": "12.23.24",
"next-themes": "0.4.6",
"react-day-picker": "8.10.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-hook-form": "7.55.0",
"react-popper": "2.3.0",
"react-resizable-panels": "2.1.7",
"react-responsive-masonry": "2.7.1",
"react-router": "7.13.0",
"react-slick": "0.31.0",
"recharts": "2.15.2",
"sonner": "2.0.3",
"tailwind-merge": "3.2.0",
"tw-animate-css": "1.3.8",
"vaul": "1.1.2"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.12",
"@vitejs/plugin-react": "4.7.0",
"tailwindcss": "4.1.12",
"vite": "6.3.5"
},
"peerDependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
},
"pnpm": {
"overrides": {
"vite": "6.3.5"
}
}
}
\ No newline at end of file
packages:
- '.'
\ No newline at end of file
/**
* PostCSS Configuration
*
* Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required
* PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here.
*
* This file only exists for adding additional PostCSS plugins, if needed.
* For example:
*
* import postcssNested from 'postcss-nested'
* export default { plugins: [postcssNested()] }
*
* Otherwise, you can leave this file empty.
*/
export default {}
import { RouterProvider } from 'react-router';
import { router } from './routes';
export default function App() {
return <RouterProvider router={router} />;
}
\ No newline at end of file
This diff is collapsed.
import { Navigate, useLocation } from "react-router";
import { useAuthStore } from "../../stores/authStore";
interface Props {
children: React.ReactNode;
}
export function PrivateRoute({ children }: Props) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
import React, { useState } from 'react'
const ERROR_IMG_SRC =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [didError, setDidError] = useState(false)
const handleError = () => {
setDidError(true)
}
const { src, alt, style, className, ...rest } = props
return didError ? (
<div
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
style={style}
>
<div className="flex items-center justify-center w-full h-full">
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
</div>
</div>
) : (
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
)
}
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "./utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "./utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "./utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export { Calendar };
import * as React from "react";
import { cn } from "./utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<h4
data-slot="card-title"
className={cn("leading-none", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 [&:last-child]:pb-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "./utils";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
This diff is collapsed.
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "./utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "./utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "./utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "./utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "./utils";
import { Label } from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "./utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "./utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
import * as React from "react";
import { cn } from "./utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "./utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className,
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment