Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

55
.cloud-dev/.dockerignore Normal file
View File

@@ -0,0 +1,55 @@
# 依赖目录
node_modules
.pnpm-store
# 构建输出
.next
dist
build
out
# 日志文件
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# 环境变量文件
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# 编辑器和 IDE
.vscode
.idea
*.swp
*.swo
*~
# 操作系统文件
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# 测试覆盖率
coverage
# 临时文件
tmp
temp
*.tmp
# Docker 相关
Dockerfile
docker-compose.yml
.dockerignore
# 其他
.cache
.turbo

141
.cloud-dev/Dockerfile Normal file
View File

@@ -0,0 +1,141 @@
FROM ubuntu:22.04
# 设置环境变量
ENV DEBIAN_FRONTEND=noninteractive \
NODE_VERSION=22.14.0 \
PYTHON_VERSION=3.12 \
CODE_SERVER_VERSION=4.96.2 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
TZ=Asia/Shanghai \
DEV_PASSWORD=clouddev
# 安装基础工具和依赖
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
openssh-server \
tmux \
tree \
pwgen \
zip \
unzip \
net-tools \
fontconfig \
ffmpeg \
ca-certificates \
gnupg \
lsb-release \
build-essential \
libssl-dev \
zlib1g-dev \
libbz2-dev \
libreadline-dev \
libsqlite3-dev \
libncursesw5-dev \
xz-utils \
tk-dev \
libxml2-dev \
libxmlsec1-dev \
libffi-dev \
liblzma-dev \
ttyd \
cmake \
telnet \
redis-tools \
potrace \
imagemagick \
zsh \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 3.12
RUN apt-get update && apt-get install -y software-properties-common && \
add-apt-repository ppa:deadsnakes/ppa && \
apt-get update && \
apt-get install -y python3.12 python3.12-venv python3.12-dev python3-pip && \
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \
update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 && \
rm -rf /var/lib/apt/lists/*
# 安装 Node.js 22.14
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# 安装 pnpm
RUN npm install -g pnpm
# 安装 uv (Python package manager)
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
# 安装 oh-my-zsh
RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
# 设置 zsh 为默认 shell
RUN chsh -s $(which zsh)
# 配置 zsh
RUN echo 'export ZSH="$HOME/.oh-my-zsh"' > /root/.zshrc && \
echo 'ZSH_THEME="robbyrussell"' >> /root/.zshrc && \
echo 'plugins=(git node npm docker python)' >> /root/.zshrc && \
echo 'source $ZSH/oh-my-zsh.sh' >> /root/.zshrc && \
echo '' >> /root/.zshrc && \
echo '# 添加 uv 到 PATH' >> /root/.zshrc && \
echo 'export PATH="$HOME/.local/bin:$PATH"' >> /root/.zshrc
# 安装 MinIO Client (mc)
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc && \
chmod +x /usr/local/bin/mc
# 安装 code-server
RUN curl -fsSL https://code-server.dev/install.sh | sh -s -- --version=${CODE_SERVER_VERSION}
# 安装 npm 全局包
RUN npm install -g \
@anthropic-ai/claude-code \
@musistudio/claude-code-router
# 创建工作目录
RUN mkdir -p /workspace /root/.local/share/code-server/User
# 配置 SSH
RUN mkdir -p /var/run/sshd && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config
# 安装 code-server 插件
RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
&& code-server --install-extension bierner.markdown-mermaid \
&& code-server --install-extension ms-python.python \
&& code-server --install-extension rooveterinaryinc.roo-cline \
&& code-server --install-extension dbaeumer.vscode-eslint \
&& code-server --install-extension prisma.prisma \
&& code-server --install-extension ecmel.vscode-html-css \
&& code-server --install-extension cweijan.vscode-redis-client
# 配置 code-server (密码将在启动时设置)
RUN mkdir -p /root/.config/code-server && \
echo 'bind-addr: 0.0.0.0:8080' > /root/.config/code-server/config.yaml && \
echo 'auth: password' >> /root/.config/code-server/config.yaml && \
echo 'cert: false' >> /root/.config/code-server/config.yaml
# 配置 tmux
RUN echo 'set -g mouse on' > /root/.tmux.conf && \
echo 'set -g history-limit 10000' >> /root/.tmux.conf && \
echo 'set -g default-terminal "screen-256color"' >> /root/.tmux.conf
# 复制启动脚本
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# 暴露端口
# 22: SSH
# 8080: code-server
# 7681: ttyd
# 3000: Next.js dev server
EXPOSE 22 8080 7681 3000
WORKDIR /workspace
ENTRYPOINT ["/entrypoint.sh"]

335
.cloud-dev/README.md Normal file
View File

@@ -0,0 +1,335 @@
# 云开发容器
本目录包含 Hair Keeper 项目的云开发容器配置,提供完整的开发环境,无需在本地安装任何开发工具。
## 📦 容器内容
### 开发环境
- **Node.js**: 22.14.0
- **pnpm**: 最新版本
- **Python**: 3.12
- **Code Server**: 4.96.2 (基于 VS Code 的 Web IDE)
- **Git**: 版本控制工具
### Shell 环境
- **zsh**: 默认 Shell
- **oh-my-zsh**: zsh 配置框架
- **tmux**: 终端复用器
### 命令行工具
- **ttyd**: Web 终端(可选启动)
- **tree**: 目录树显示
- **pwgen**: 密码生成器
- **curl/wget**: 下载工具
- **ffmpeg**: 多媒体处理
- **mc**: MinIO 客户端
- **zip/unzip**: 压缩工具
- **net-tools**: 网络工具
- **ssh**: 远程连接
- **cmake**: 构建工具
- **telnet**: 网络调试
- **redis-tools**: Redis 命令行工具
- **potrace**: 位图转矢量图
- **imagemagick**: 图像处理工具
- **uv**: 快速 Python 包管理器
### NPM 全局包
- `@anthropic-ai/claude-code`: Claude AI 代码助手
- `@musistudio/claude-code-router`: Claude 代码路由器
### VS Code 插件
- **vscode-pdf**: PDF 查看器
- **Markdown Preview Mermaid Support**: Mermaid 图表支持
- **Python**: Python 语言支持
- **Roo Code**: Roo 代码助手
- **ESLint**: JavaScript/TypeScript 代码检查
- **HTML Preview**: HTML 预览
- **Prisma**: Prisma ORM 支持
- **HTML CSS Support**: HTML/CSS 智能提示
- **Redis**: Redis 客户端
## 🚀 使用方法
### 方式一:使用 Docker Compose推荐
#### 使用默认密码启动
```zsh
cd .cloud-dev
docker compose up -d
```
#### 自定义密码启动
修改 `docker-compose.yml` 中的 `DEV_PASSWORD` 环境变量,或使用环境变量覆盖:
```zsh
DEV_PASSWORD=your_password docker compose up -d
```
停止容器:
```zsh
docker compose down
```
查看日志:
```zsh
docker compose logs -f
```
重新构建:
```zsh
docker compose up -d --build
```
### 方式二:使用 Docker 命令
#### 1. 构建镜像
```zsh
cd .cloud-dev
docker build -t hair-keeper-dev .
```
#### 2. 运行容器
使用默认密码:
```zsh
docker run -d \
--name hair-keeper-dev \
-p 2222:22 \
-p 8080:8080 \
-p 7681:7681 \
-p 3000:3000 \
-v $(pwd)/..:/workspace:cached \
-v hair-keeper-node-modules:/workspace/node_modules \
-v hair-keeper-pnpm-store:/root/.local/share/pnpm/store \
hair-keeper-dev
```
使用自定义密码:
```zsh
docker run -d \
--name hair-keeper-dev \
-e DEV_PASSWORD=your_password \
-p 2222:22 \
-p 8080:8080 \
-p 7681:7681 \
-p 3000:3000 \
-v $(pwd)/..:/workspace:cached \
-v hair-keeper-node-modules:/workspace/node_modules \
-v hair-keeper-pnpm-store:/root/.local/share/pnpm/store \
hair-keeper-dev
```
### 3. 访问开发环境
容器启动后,可以通过以下方式访问:
#### Code Server (Web IDE)
- **地址**: http://localhost:8080
- **默认密码**: `clouddev`(可通过 `DEV_PASSWORD` 环境变量自定义)
- 提供完整的 VS Code 开发体验
- 内置终端使用 zsh + oh-my-zsh
#### SSH 连接
```zsh
ssh root@localhost -p 2222
# 默认密码: clouddev可通过 DEV_PASSWORD 环境变量自定义)
```
#### Next.js 开发服务器
- **地址**: http://localhost:3000
- 在容器内运行 `pnpm run dev` 启动
## 📝 端口映射
| 服务 | 容器端口 | 主机端口 | 说明 |
|------|---------|---------|------|
| SSH | 22 | 2222 | SSH 远程连接 |
| Code Server | 8080 | 8080 | Web IDE |
| Next.js | 3000 | 3000 | 开发服务器 |
**注意**: ttyd (Web 终端) 默认不启动,如需使用可在容器内手动运行:
```zsh
ttyd -p 7681 zsh
```
然后通过 http://localhost:7681 访问(需要在 docker run 或 docker-compose.yml 中映射 7681 端口)
## 🔧 常用操作
### 进入容器
```zsh
docker exec -it hair-keeper-dev zsh
```
### 安装项目依赖
```zsh
docker exec -it hair-keeper-dev pnpm install
```
### 启动开发服务器
```zsh
docker exec -it hair-keeper-dev pnpm run dev
```
### 查看容器日志
```zsh
docker logs -f hair-keeper-dev
```
### 查看 Code Server 日志
Code Server 的日志位于容器内的 `/root/.local/share/code-server/coder-logs/` 目录,包含两个日志文件:
```zsh
# 查看日志文件列表
docker exec -it hair-keeper-dev ls -lt /root/.local/share/code-server/coder-logs/
# 实时查看标准输出日志
docker exec -it hair-keeper-dev tail -f /root/.local/share/code-server/coder-logs/code-server-stdout.log
# 实时查看错误日志
docker exec -it hair-keeper-dev tail -f /root/.local/share/code-server/coder-logs/code-server-stderr.log
# 同时查看两个日志文件
docker exec -it hair-keeper-dev tail -f /root/.local/share/code-server/coder-logs/code-server-*.log
# 或者在容器内查看
docker exec -it hair-keeper-dev zsh
cd /root/.local/share/code-server/coder-logs/
tail -f code-server-stdout.log code-server-stderr.log
```
### 停止容器
```zsh
docker stop hair-keeper-dev
```
### 删除容器
```zsh
docker rm hair-keeper-dev
```
## 🎯 工作流程
1. **启动容器**: 运行上述 docker run 命令
2. **访问 Code Server**: 在浏览器打开 http://localhost:8080
3. **打开项目**: Code Server 会自动打开 /workspace 目录(映射到项目根目录)
4. **安装依赖**: 在终端运行 `pnpm install`
5. **启动开发**: 运行 `pnpm run dev`
6. **开始开发**: 在浏览器访问 http://localhost:3000
## 💡 提示
### Shell 环境
容器默认使用 **zsh** 作为 Shell配置了 **oh-my-zsh** 框架:
- 主题robbyrussell
- 插件git, node, npm, docker, python
- 自动补全和语法高亮
- 更友好的命令行体验
### Python 包管理
推荐使用 **uv** 进行 Python 包管理,它比 pip 快得多:
```zsh
# 创建虚拟环境
uv venv
# 安装包
uv pip install package-name
# 从 requirements.txt 安装
uv pip install -r requirements.txt
```
### 数据持久化
- 项目代码通过 volume 映射,修改会实时同步到主机
- node_modules 建议使用 Docker volume 以提高性能
### 性能优化
如果需要更好的性能,可以使用命名卷:
```zsh
docker volume create hair-keeper-node-modules
docker run -d \
--name hair-keeper-dev \
-p 2222:22 \
-p 8080:8080 \
-p 7681:7681 \
-p 3000:3000 \
-v $(pwd)/..:/workspace \
-v hair-keeper-node-modules:/workspace/node_modules \
hair-keeper-dev
```
### 运行时修改密码
如果需要在容器运行时修改密码:
**方法一:重启容器并设置新密码**
```zsh
docker stop hair-keeper-dev
docker rm hair-keeper-dev
# 使用新密码重新启动
DEV_PASSWORD=new_password docker compose up -d
```
**方法二:在容器内手动修改**
```zsh
# SSH 密码
docker exec -it hair-keeper-dev passwd
# Code Server 密码
docker exec -it hair-keeper-dev zsh -c "echo 'password: new_password' >> /root/.config/code-server/config.yaml"
# 然后重启 code-server
```
## 🔐 密码配置
### 默认密码
容器默认密码为 `clouddev`,用于:
- SSH 登录root 用户)
- Code Server Web IDE
### 自定义密码
通过环境变量 `DEV_PASSWORD` 设置自定义密码:
**Docker Compose 方式**
编辑 `docker-compose.yml`
```yaml
environment:
- DEV_PASSWORD=your_secure_password
```
**Docker 命令方式**
```zsh
docker run -e DEV_PASSWORD=your_secure_password ...
```
### 安全建议
- ⚠️ 生产环境务必修改默认密码
- 建议使用 SSH 密钥认证替代密码
- 不要将容器直接暴露到公网
- 使用反向代理(如 Nginx并配置 HTTPS
- 定期更换密码
## 📚 相关资源
- [Code Server 文档](https://coder.com/docs/code-server)
- [Docker 文档](https://docs.docker.com/)
- [tmux 快捷键](https://tmuxcheatsheet.com/)
## 🐛 故障排除
### 端口被占用
如果端口被占用,可以修改主机端口映射:
```zsh
-p 8081:8080 # 将 Code Server 映射到 8081
```
### 权限问题
如果遇到文件权限问题,确保容器内的用户有正确的权限:
```zsh
docker exec -it hair-keeper-dev chown -R root:root /workspace
```
### 容器无法启动
查看容器日志排查问题:
```zsh
docker logs hair-keeper-dev

View File

@@ -0,0 +1,42 @@
services:
cloud-dev:
build:
context: .
dockerfile: Dockerfile
container_name: hair-keeper-dev
hostname: hair-keeper-dev
ports:
- "2222:22" # SSH
- "8080:8080" # Code Server
- "7681:7681" # ttyd (Web Terminal)
- "3000:3000" # Next.js Dev Server
volumes:
# 项目代码映射(使用 cached 模式提高性能)
- ../:/workspace:cached
# node_modules 使用命名卷以提高性能
- node_modules:/workspace/node_modules
# pnpm store 缓存
- pnpm_store:/root/.local/share/pnpm/store
# Git 配置(可选,如果需要保留 Git 配置)
- ~/.gitconfig:/root/.gitconfig:ro
environment:
- NODE_ENV=development
- TZ=Asia/Shanghai
# 开发环境密码,可自定义修改
- DEV_PASSWORD=clouddev
restart: unless-stopped
# 资源限制(可选,根据需要调整)
deploy:
resources:
limits:
cpus: '4'
memory: 8G
reservations:
cpus: '2'
memory: 4G
volumes:
# node_modules 卷,避免主机和容器之间的文件系统差异
node_modules:
# pnpm store 卷,加速依赖安装
pnpm_store:

46
.cloud-dev/entrypoint.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# 设置默认密码
DEV_PASSWORD=${DEV_PASSWORD:-clouddev}
# 设置 root 密码
echo "root:${DEV_PASSWORD}" | chpasswd
# 确保 code-server 配置目录存在
mkdir -p /root/.local/share/code-server
# 创建 code-server 配置文件
cat > /root/.config/code-server/config.yaml << EOF
bind-addr: 0.0.0.0:8080
auth: password
password: ${DEV_PASSWORD}
cert: false
EOF
# 启动 SSH 服务
echo "Starting SSH service..."
service ssh start
# 启动 code-server
echo "Starting code-server on port 8080..."
code-server --bind-addr 0.0.0.0:8080 /workspace &
# 输出服务信息
echo ""
echo "=========================================="
echo "Cloud Development Container Started"
echo "=========================================="
echo "SSH: Port 22 (user: root, password: ${DEV_PASSWORD})"
echo "Code Server: Port 8080 (password: ${DEV_PASSWORD})"
echo "Next.js Dev: Port 3000 (run 'pnpm run dev' to start)"
echo "=========================================="
echo ""
echo "Workspace: /workspace"
echo "Default Shell: zsh with oh-my-zsh"
echo ""
echo "提示: 可通过环境变量 DEV_PASSWORD 自定义密码"
echo ""
# 保持容器运行
tail -f /dev/null

61
.env.example Normal file
View File

@@ -0,0 +1,61 @@
# ============================================
# AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
# 自动生成的文件 - 请勿手动修改
# ============================================
# This file is automatically generated by .env to help developers or AI understand the environment variables in the project
# 此文件由 .env 自动生成用于帮助开发人员或者AI了解项目中有哪些环境变量
#
# Purpose: Template for environment variables
# 用途:环境变量配置模板
#
# Usage:
# 使用方法:
# 1. Copy this file: cp .env.example .env
# 复制此文件cp .env.example .env
# 2. Fill in your actual values in .env
# 在 .env 中填写实际的配置值
# 3. Never commit .env to Git!
# 永远不要将 .env 提交到 Git
# ============================================
# 默认配置文件,在所有环境下都会加载
# 容器相关
POSTGRESQL_USERNAME=
POSTGRESQL_PASSWORD=
POSTGRESQL_PORT=
DATABASE_URL=
REDIS_PORT=
REDIS_PASSWORD=
MINIO_ENDPOINT=
MINIO_API_PORT=
MINIO_CONSOLE_PORT=
MINIO_USE_SSL=
MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD=
MINIO_SERVER_URL=
MINIO_BUCKET=
# 应用相关
SUPER_ADMIN_PASSWORD=
# NextAuth.js Configuration
NEXTAUTH_SECRET=
NEXTAUTH_URL=
PKUAI_API_KEY=
PKUAI_API_BASE=
# 仅在开发环境加载(写在.env.development中
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=
DEV_TERMINAL=
# 仅在生产环境加载(写在.env.production中

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env*.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/postgresql
tasks.md
/prisma/zod
# pnpm
.pnpm-store/
# lock 一般的项目需要用git管理但这个是模板项目就不管理
package-lock.json
pnpm-lock.yaml

12
.roo/mcp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"mcpServers":{
"ai-elements": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://registry.ai-sdk.dev/api/mcp"
]
}
}
}

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
## 项目说明
本项目模板Hair Keeper v1.0.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
## 主要依赖库
- 基础next + react + trpc + prisma
- UI基础框架tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
- 图表等高级UIrecharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
- 用户交互增强motion(动画) + framer-motion(动画) + use-gesture/react(手势)
- Headless UIreact-hook-form + tanstack/react-table + headless-tree/react
- 数据和存储pg(PostgreSQL) + ioredis + minio
- 后台任务及消息队列bullmq
- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件)
- 辅助性库lodash + zod + date-fns + nanoid
- 其他next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard
## 项目约定
### 前端
- 可使用`pnpx shadcn@latest add`添加UI控件会添加到components/ui中
- 表单输入组件提供value、onChange和disabled三种属性的支持可方便的集成到react-hook-form中
- z-index约定常规对话框及其遮罩z-50表单控件的Popover为z-60全屏预览及遮罩z-70
- tailwindcss v4支持直接编写如 w-[10px] 这样的任意值非必要不写style支持样式类合并`import { cn } from "@/lib/utils"`
-`import { useCallbackRef } from "@/hooks/use-callback-ref"`这个钩子构建引用不变但逻辑总是最新的函数,解决闭包陷阱
### 后端
- tRPC接口报错时可使用TRPCError例如throw new TRPCError({ code: 'NOT_FOUND', message: '' })
- server/trpc.ts中预定义了publicProcedure用于创建无权限限制、也不需要登录的api
- server/trpc.ts中预定义了publicProcedurepermissionRequiredProcedure用来创建限制特定权限访问的路由例如permissionRequiredProcedure(Permissions.USER_MANAGE)空字符串表示无权限要求但是需要用户登录约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问该权限不在Permissions中定义只有超级管理员才能绕过授权限制访问所有接口因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。
### 数据和存储
- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`
- 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient
- 时间字段统一使用 `@db.Timestamptz` 类型
- 前后端参数传递尽量使用扁平结构而非嵌套结构
- 文件的上传和下载采用“客户端直传”架构基于MinIO服务器端只负责授权和生成预签名URL
### 开发模式
为了方便开发,本项目模板内置了在开发模式下可用的大量页面和功能
- 仅在开发阶段使用的页面、布局、api等会被NextJS识别并处理的文件以dev.tsx、dev.ts、dev.jsx、dev.js为后缀不会被打包到生产环境
- 仅在开发阶段使用的数据模型以Dev开头对应的数据表以dev_开头
## 重要目录和文件
### 前端
- `components/common/`进一步封装的高级通用控件例如下拉菜单、对话框表单、响应式tabs等控件
- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
- `components/ai-elements/`ai对话相关的组件
- `components/data-details/`专注于数据展示的可复用控件例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
- `components/data-table/`专注于数据表格的可复用控件本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
- `components/icons/`:项目的自定义图标可以写在这个文件夹
- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
- `components/ui/`高度可定制可复用的基础UI组件通常源自第三方库
- `app/(main)/`:开发者在这里自行实现的所有业务的页面
- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
- `app/(main)/users/`用户管理模块提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
- `hooks/`可复用React Hooks库部分复杂的组件也通过hook实现Headless UI逻辑与样式分离组件中可复用的逻辑都可以放在这
- `lib/trpc.ts`创建并导出tRPC React客户端实例用于前端与后端API通信
### 后端
- `server/routers/`项目trpc api定义文件开发者主要在这里定义和实现业务的后端API
- `server/routers/_app.ts``appRouter`根路由定义,需要添加子路由时在此处注册
- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
- `server/routers/jobs.ts`tRPC任务进度订阅路由
- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
- `server/routers/global.ts`系统全局和特定业务关联不大的一些api
- `server/routers/dev/`开发模式下的辅助功能需要的trpc api
- `server/queues/`消息队列和worker通过其中的index.ts统一导出任务状态更新采用trpc SSE subscription接口定义在`server/routers/jobs.ts`
- `server/agents`LLM的对接和使用
- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
- `server/service/dev/`:开发模式下的辅助功能需要的后台服务
- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
- `api/dev/`开发模式下的辅助功能需要的api
### 其他
- `constants/`:项目全局常量管理
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)"
- `lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
- `lib/format.ts`:数据格式化工具函数库
## 非标准命令
- `pnpm run dev:attach`这会使用tmux在名为nextdev的session中启动pnpm run dev便于在开发页面或其他地方与开发服务器交互
- `pnpm run db:seed`:数据库种子数据,`prisma/init_data/`存放种子初始化数据
- `pnpm run build:analyze`打包项目并使用next/bundle-analyzer进行分析

83
components.json Normal file
View File

@@ -0,0 +1,83 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json",
"@reui": "https://reui.io/r/{name}.json",
"@kibo-ui": "https://www.kibo-ui.com/r/{name}.json",
"@paceui-ui": "https://ui.paceui.com/r/{name}.json",
"@heseui": "https://www.heseui.com/r/{name}.json",
"@blocks": "https://blocks.so/r/{name}.json",
"@elements": "https://tryelements.dev/r/{name}.json",
"@smoothui": "https://smoothui.dev/r/{name}.json",
"@formcn": "https://formcn.dev/r/{name}.json",
"@limeplay": "https://limeplay.winoffrg.dev/r/{name}.json",
"@rigidui": "https://rigidui.com/r/{name}.json",
"@retroui": "https://retroui.dev/r/{name}.json",
"@wds": "https://wds-shadcn-registry.netlify.app/r/{name}.json",
"@aceternity": "https://ui.aceternity.com/registry/{name}.json",
"@alexcarpenter": "https://ui.alexcarpenter.me/r/{name}.json",
"@algolia": "https://sitesearch.algolia.com/r/{name}.json",
"@aliimam": "https://aliimam.in/r/{name}.json",
"@animate-ui": "https://animate-ui.com/r/{name}.json",
"@assistant-ui": "https://r.assistant-ui.com/{name}.json",
"@austin-ui": "https://austin-ui.netlify.app/r/{name}.json",
"@better-upload": "https://better-upload.com/r/{name}.json",
"@billingsdk": "https://billingsdk.com/r/{name}.json",
"@bucharitesh": "https://bucharitesh.in/r/{name}.json",
"@clerk": "https://clerk.com/r/{name}.json",
"@coss": "https://coss.com/ui/r/{name}.json",
"@chisom-ui": "https://chisom-ui.netlify.app/r/{name}.json",
"@creative-tim": "https://www.creative-tim.com/ui/r/{name}.json",
"@cult-ui": "https://cult-ui.com/r/{name}.json",
"@diceui": "https://diceui.com/r/{name}.json",
"@eldoraui": "https://eldoraui.site/r/{name}.json",
"@elevenlabs-ui": "https://ui.elevenlabs.io/r/{name}.json",
"@fancy": "https://fancycomponents.dev/r/{name}.json",
"@kokonutui": "https://kokonutui.com/r/{name}.json",
"@lens-blocks": "https://lensblocks.com/r/{name}.json",
"@lytenyte": "https://www.1771technologies.com/r/{name}.json",
"@magicui": "https://magicui.design/r/{name}.json",
"@magicui-pro": "https://pro.magicui.design/registry/{name}",
"@motion-primitives": "https://motion-primitives.com/c/{name}.json",
"@nuqs": "https://nuqs.dev/r/{name}.json",
"@paceui": "https://ui.paceui.com/r/{name}.json",
"@plate": "https://platejs.org/r/{name}.json",
"@prompt-kit": "https://prompt-kit.com/c/{name}.json",
"@prosekit": "https://prosekit.dev/r/{name}.json",
"@react-bits": "https://reactbits.dev/r/{name}.json",
"@react-market": "https://www.react-market.com/get/{name}.json",
"@solaceui": "https://www.solaceui.com/r/{name}.json",
"@scrollxui": "https://www.scrollxui.dev/registry/{name}.json",
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
"@shadcnblocks": "https://shadcnblocks.com/r/{name}.json",
"@simple-ai": "https://simple-ai.dev/r/{name}.json",
"@skyr": "https://ui-play.skyroc.me/r/{name}.json",
"@spectrumui": "https://ui.spectrumhq.in/r/{name}.json",
"@supabase": "https://supabase.com/ui/r/{name}.json",
"@svgl": "https://svgl.app/r/{name}.json",
"@tailark": "https://tailark.com/r/{name}.json",
"@tweakcn": "https://tweakcn.com/r/themes/{name}.json",
"@paykit-sdk": "https://www.usepaykit.dev/r/{name}.json",
"@pixelact-ui": "https://www.pixelactui.com/r/{name}.json",
"@zippystarter": "https://zippystarter.com/r/{name}.json",
"@ha-components": "https://hacomponents.keshuac.com/r/{name}.json"
}
}

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
services:
postgresql:
image: bitnami/postgresql:17 # https://hub.docker.com/r/bitnami/postgresql
container_name: hair-keeper-dev-pg
restart: always
ports:
- "${POSTGRESQL_PORT}:5432"
environment:
POSTGRESQL_USERNAME: ${POSTGRESQL_USERNAME}
POSTGRESQL_PASSWORD: ${POSTGRESQL_PASSWORD}
POSTGRESQL_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
volumes:
- hair_keeper_postgresql_data:/bitnami/postgresql
redis:
image: redis:8-alpine # https://hub.docker.com/_/redis
container_name: hair-keeper-dev-redis
ports:
- "${REDIS_PORT}:6379"
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- hair_keeper_redis_data:/data
restart: always
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
container_name: hair-keeper-dev-minio
ports:
- "${MINIO_API_PORT}:9000" # API端口
- "${MINIO_CONSOLE_PORT}:9001" # Console端口
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_SERVER_URL: ${MINIO_SERVER_URL}
volumes:
- hair_keeper_minio_data:/data
command: server /data --console-address ":9001"
restart: always
volumes:
hair_keeper_postgresql_data:
hair_keeper_redis_data:
hair_keeper_minio_data:

35
eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"public/**",
],
},
{
rules: {
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"prefer-const": "off",
"@next/next/no-img-element": "off"
},
},
];
export default eslintConfig;

17
instrumentation.ts Normal file
View File

@@ -0,0 +1,17 @@
// 这里的代码只会在服务器启动时执行一次
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// 初始化定时任务
const { initCronJobs } = await import('@/server/cron')
await initCronJobs()
if (process.env.NODE_ENV === 'development') {
const { startTerminalService } = await import('@/server/service/dev/terminal');
startTerminalService() // 开发环境下启动一个基于ttyd可嵌入在网页上的终端服务
}
} else {
}
}

18
next.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
};
if (process.env.NODE_ENV === 'development') {
const pageExtensions = nextConfig.pageExtensions || ['ts', 'tsx', 'js', 'jsx'];
nextConfig.pageExtensions = ['dev.ts', 'dev.tsx', 'dev.js', 'dev.jsx', ...pageExtensions];
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
export default withBundleAnalyzer(nextConfig);

118
package.json Normal file
View File

@@ -0,0 +1,118 @@
{
"name": "hair-keeper",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000 --turbo",
"dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M",
"build": "next build",
"start": "next start -p 3000",
"lint": "eslint",
"db:seed": "tsx prisma/seed.ts",
"build:analyze": "ANALYZE=true next build"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.29",
"@ai-sdk/openai": "^2.0.52",
"@ai-sdk/react": "^2.0.71",
"@auth/prisma-adapter": "^2.10.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headless-tree/core": "^1.5.1",
"@headless-tree/react": "^1.5.1",
"@hookform/resolvers": "^5.2.2",
"@next/bundle-analyzer": "^15.5.6",
"@prisma/client": "^6.15.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@trpc/client": "^11.5.1",
"@trpc/next": "^11.5.1",
"@trpc/react-query": "^11.5.1",
"@trpc/server": "^11.5.1",
"@types/dagre": "^0.7.53",
"@types/pg": "^8.15.5",
"@use-gesture/react": "^10.3.1",
"@xyflow/react": "^12.8.6",
"ai": "^5.0.71",
"bcryptjs": "^3.0.2",
"browser-image-compression": "^2.0.2",
"bullmq": "^5.61.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3",
"dagre": "^0.8.5",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.24",
"ioredis": "^5.8.1",
"lodash": "^4.17.21",
"lucide-react": "^0.543.0",
"minio": "^8.0.6",
"motion": "^12.23.22",
"nanoid": "^5.1.6",
"next": "~15.4.0",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"nuqs": "^2.6.0",
"pg": "^8.16.3",
"prism-react-renderer": "^2.4.1",
"prisma": "^6.15.0",
"radix-ui": "^1.4.3",
"react": "~19.1.0",
"react-day-picker": "^9.11.0",
"react-dom": "~19.1.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.62.0",
"recharts": "^3.2.0",
"shiki": "^3.15.0",
"sonner": "^2.0.7",
"streamdown": "^1.4.0",
"superjson": "^2.2.2",
"tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^4.1.9"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/lodash": "^4.17.20",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"react-icons": "^5.5.0",
"react-live": "^4.1.8",
"shadcn": "^3.5.0",
"tailwindcss": "^4",
"tsx": "^4.20.5",
"tw-animate-css": "^1.3.8",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1102
prisma/init_data/院系.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT,
"status" TEXT,
"dept_code" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"password" TEXT NOT NULL,
"is_super_admin" BOOLEAN NOT NULL DEFAULT false,
"last_login_at" TIMESTAMPTZ,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dept" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"full_name" TEXT NOT NULL DEFAULT '',
CONSTRAINT "dept_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "role" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permission" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "selection_log" (
"id" SERIAL NOT NULL,
"userId" TEXT NOT NULL,
"context" TEXT NOT NULL,
"option_id" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "selection_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dev_file_type" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"order" INTEGER NOT NULL,
CONSTRAINT "dev_file_type_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dev_pkg_type" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"order" INTEGER NOT NULL,
CONSTRAINT "dev_pkg_type_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dev_analyzed_file" (
"id" SERIAL NOT NULL,
"path" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"commit_id" TEXT NOT NULL DEFAULT '',
"content" TEXT,
"file_type_id" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"description" TEXT NOT NULL,
"exportedMembers" JSONB,
"tags" TEXT[],
"lastAnalyzedAt" TIMESTAMPTZ NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dev_analyzed_file_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dev_analyzed_pkg" (
"name" TEXT NOT NULL,
"version" TEXT NOT NULL,
"modifiedAt" TIMESTAMP(3) NOT NULL,
"description" TEXT NOT NULL,
"homepage" TEXT,
"repository_url" TEXT,
"pkg_type_id" TEXT NOT NULL,
"projectRoleSummary" TEXT NOT NULL,
"primaryUsagePattern" TEXT NOT NULL,
"relatedFiles" TEXT[],
"relatedFileCount" INTEGER NOT NULL,
"last_analyzed_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dev_analyzed_pkg_pkey" PRIMARY KEY ("name")
);
-- CreateTable
CREATE TABLE "dev_file_dependency" (
"id" SERIAL NOT NULL,
"source_file_id" INTEGER NOT NULL,
"target_file_path" TEXT NOT NULL,
"usage_description" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dev_file_dependency_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dev_file_pkg_dependency" (
"id" SERIAL NOT NULL,
"source_file_id" INTEGER NOT NULL,
"package_name" TEXT NOT NULL,
"usage_description" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dev_file_pkg_dependency_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dev_analyzed_folder" (
"path" TEXT NOT NULL,
"name" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"description" TEXT NOT NULL,
"last_analyzed_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dev_analyzed_folder_pkey" PRIMARY KEY ("path")
);
-- CreateTable
CREATE TABLE "_RoleToUser" (
"A" INTEGER NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_RoleToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_PermissionToRole" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_PermissionToRole_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "role_name_key" ON "role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "permission_name_key" ON "permission"("name");
-- CreateIndex
CREATE INDEX "selection_log_userId_context_idx" ON "selection_log"("userId", "context");
-- CreateIndex
CREATE INDEX "selection_log_context_option_id_idx" ON "selection_log"("context", "option_id");
-- CreateIndex
CREATE UNIQUE INDEX "dev_analyzed_file_path_commit_id_key" ON "dev_analyzed_file"("path", "commit_id");
-- CreateIndex
CREATE INDEX "dev_analyzed_pkg_pkg_type_id_idx" ON "dev_analyzed_pkg"("pkg_type_id");
-- CreateIndex
CREATE INDEX "dev_file_dependency_target_file_path_idx" ON "dev_file_dependency"("target_file_path");
-- CreateIndex
CREATE UNIQUE INDEX "dev_file_dependency_source_file_id_target_file_path_key" ON "dev_file_dependency"("source_file_id", "target_file_path");
-- CreateIndex
CREATE INDEX "dev_file_pkg_dependency_package_name_idx" ON "dev_file_pkg_dependency"("package_name");
-- CreateIndex
CREATE UNIQUE INDEX "dev_file_pkg_dependency_source_file_id_package_name_key" ON "dev_file_pkg_dependency"("source_file_id", "package_name");
-- CreateIndex
CREATE INDEX "_RoleToUser_B_index" ON "_RoleToUser"("B");
-- CreateIndex
CREATE INDEX "_PermissionToRole_B_index" ON "_PermissionToRole"("B");
-- AddForeignKey
ALTER TABLE "user" ADD CONSTRAINT "user_dept_code_fkey" FOREIGN KEY ("dept_code") REFERENCES "dept"("code") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "selection_log" ADD CONSTRAINT "selection_log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dev_analyzed_file" ADD CONSTRAINT "dev_analyzed_file_file_type_id_fkey" FOREIGN KEY ("file_type_id") REFERENCES "dev_file_type"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dev_analyzed_pkg" ADD CONSTRAINT "dev_analyzed_pkg_pkg_type_id_fkey" FOREIGN KEY ("pkg_type_id") REFERENCES "dev_pkg_type"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dev_file_dependency" ADD CONSTRAINT "dev_file_dependency_source_file_id_fkey" FOREIGN KEY ("source_file_id") REFERENCES "dev_analyzed_file"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dev_file_pkg_dependency" ADD CONSTRAINT "dev_file_pkg_dependency_source_file_id_fkey" FOREIGN KEY ("source_file_id") REFERENCES "dev_analyzed_file"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PermissionToRole" ADD CONSTRAINT "_PermissionToRole_A_fkey" FOREIGN KEY ("A") REFERENCES "permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PermissionToRole" ADD CONSTRAINT "_PermissionToRole_B_fkey" FOREIGN KEY ("B") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "kv_config" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "kv_config_pkey" PRIMARY KEY ("key")
);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

243
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,243 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 用户表
model User {
id String @id
name String?
status String? // 在校/减离/NULL
deptCode String? @map("dept_code") // 所属院系代码(外键)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
password String
isSuperAdmin Boolean @default(false) @map("is_super_admin")
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz
// 关联
dept Dept? @relation(fields: [deptCode], references: [code])
roles Role[] // 多对多关联角色
selectionLogs SelectionLog[] // 选择日志
@@map("user")
}
// 院系表
model Dept {
code String @id
name String @default("")
fullName String @default("") @map("full_name")
// 关联
users User[]
@@map("dept")
}
// 角色表
model Role {
id Int @id @default(autoincrement())
name String @unique
users User[] // 多对多关联用户
permissions Permission[] // 多对多关联权限
@@map("role")
}
// 权限表
model Permission {
id Int @id @default(autoincrement())
name String @unique
roles Role[] // 多对多关联角色
@@map("permission")
}
// 选择日志表
model SelectionLog {
id Int @id @default(autoincrement())
userId String // 关联到用户
user User @relation(fields: [userId], references: [id])
// 用于标识是哪个的选项,用.进行分隔,例如"user.filter.dept"
context String
// 关键字段:被选中的选项的值
optionId String @map("option_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
// 建立索引,提升查询性能
@@index([userId, context])
@@index([context, optionId])
@@map("selection_log")
}
// KV配置表 - 用于存储各种键值对配置
model KVConfig {
key String @id // 配置键
value String @db.Text // 配置值
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@map("kv_config")
}
/*********************************************** 仅在开发阶段可用的数据表放在这下面 ***********************************************/
model DevFileType {
id String @id // 文件类型ID如 "COMPONENT_UI"
name String // 文件类型名称,如 "UI组件"
description String // 文件类型描述
order Int // 用来进行排序方便展示
// 关联
files DevAnalyzedFile[]
@@map("dev_file_type")
}
// 依赖包类型表
model DevPkgType {
id String @id // 依赖包类型ID如 "CORE_FRAMEWORK"
name String // 分类名称,如 "核心框架 (Core Framework)"
description String @db.Text // 职责描述
order Int // 用来进行排序方便展示
packages DevAnalyzedPkg[]
@@map("dev_pkg_type")
}
// 分析得到的项目文件信息
model DevAnalyzedFile {
id Int @id @default(autoincrement())
path String // 文件相对路径,如 "src/app/api/trpc/[trpc]/route.ts"
fileName String // 文件名,如 "route.ts"
commitId String @default("") @map("commit_id") // Git commit ID (前7位修改过的文件后面加*)
content String? @db.Text // 文件内容不超过100K的非二进制文件
fileTypeId String @map("file_type_id") // 文件类型ID外键
summary String // 主要功能一句话总结 (LLM生成)
description String // 详细功能描述 (LLM生成)
// 关键代码信息
exportedMembers Json? // 导出的函数、组件、类型、对象、列表、其他 { name, type }[]
// 元数据
tags String[] // 标签 (用于快速筛选和分类)
lastAnalyzedAt DateTime @updatedAt @db.Timestamptz
createdAt DateTime @default(now()) @db.Timestamptz // 创建时间
// 关联
fileType DevFileType @relation(fields: [fileTypeId], references: [id])
dependencies DevFileDependency[] // 该文件依赖的其他文件
pkgDependencies DevFilePkgDependency[] // 该文件依赖的包
// path 和 commitId 构成唯一键
@@unique([path, commitId], name: "uidx_path_commit")
@@map("dev_analyzed_file")
}
// 分析得到的依赖包信息
model DevAnalyzedPkg {
name String @id // 包名,如 '@tanstack/react-table'
// -- 静态信息通过读取node_modules中包的package.json获取内置包则为node的信息
version String // 版本号,如 '8.17.3'
modifiedAt DateTime // 该版本的发布时间,通过命令获取 npm view @tanstack/react-table "time[8.17.3]"
description String @db.Text // 从 package.json 中获取的官方描述
homepage String? // 包的主页URL
repositoryUrl String? @map("repository_url") // 包的仓库URL例如git+https://github.com/shadcn/ui.git
// -- 调用AI获取
pkgTypeId String @map("pkg_type_id") // 依赖包类型ID (外键)
projectRoleSummary String @db.Text // AI总结的该包的核心功能
primaryUsagePattern String @db.Text // AI总结的主要使用模式分成多点描述
// -- 统计获得
relatedFiles String[] // path[] 本次分析中,认为和该库有关联的文件(统计一定引用层数)
relatedFileCount Int // 该包在项目中被多少个文件直接或引用
// --- 时间戳 ---
lastAnalyzedAt DateTime @updatedAt @map("last_analyzed_at") @db.Timestamptz
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
// --- 关联 ---
pkgType DevPkgType @relation(fields: [pkgTypeId], references: [id])
@@index([pkgTypeId])
@@map("dev_analyzed_pkg")
}
// 文件依赖关系表
model DevFileDependency {
id Int @id @default(autoincrement())
sourceFileId Int @map("source_file_id") // 源文件ID依赖方
targetFilePath String @map("target_file_path") // 目标文件路径(被依赖方)
// AI生成的依赖用途描述
usageDescription String? @map("usage_description") @db.Text // 描述该依赖在源文件中的用途
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
// 关联
sourceFile DevAnalyzedFile @relation(fields: [sourceFileId], references: [id], onDelete: Cascade)
// 确保同一个源文件不会重复依赖同一个目标文件
@@unique([sourceFileId, targetFilePath], name: "uidx_source_target")
@@index([targetFilePath]) // 加速按目标文件路径查询
@@map("dev_file_dependency")
}
// 包依赖关系表
model DevFilePkgDependency {
id Int @id @default(autoincrement())
sourceFileId Int @map("source_file_id") // 源文件ID依赖方
packageName String @map("package_name") // 包名(如 'react' 或 '@tanstack/react-table'
// AI生成的依赖用途描述
usageDescription String? @map("usage_description") @db.Text // 描述该包在源文件中的用途和使用的功能
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
// 关联
sourceFile DevAnalyzedFile @relation(fields: [sourceFileId], references: [id], onDelete: Cascade)
// 确保同一个源文件不会重复依赖同一个包
@@unique([sourceFileId, packageName], name: "uidx_source_package")
@@index([packageName]) // 加速按包名查询
@@map("dev_file_pkg_dependency")
}
// 分析得到的项目文件夹信息
model DevAnalyzedFolder {
path String @id // 文件夹相对路径,如 "src/app/api"
name String // 文件夹名,如 "api"
summary String // 主要功能一句话总结 (LLM生成)
description String @db.Text // 详细功能描述 (LLM生成)
// 元数据
lastAnalyzedAt DateTime @updatedAt @map("last_analyzed_at") @db.Timestamptz
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@map("dev_analyzed_folder")
}

180
prisma/seed.ts Normal file
View File

@@ -0,0 +1,180 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
import { Permissions, ALL_PERMISSIONS } from '../src/constants/permissions'
import fs from 'fs'
import path from 'path'
const prisma = new PrismaClient()
// 解析 JSON 文件并导入院系数据
async function importDepartments() {
const jsonPath = path.join(__dirname, 'init_data', '院系.json')
const jsonContent = fs.readFileSync(jsonPath, 'utf-8')
const departments = JSON.parse(jsonContent)
console.log(`开始导入 ${departments.length} 个院系...`)
await Promise.all(
departments.map((dept: any) => {
return prisma.dept.upsert({
where: { code: dept.id },
update: {
name: dept.name,
fullName: dept.full_name,
},
create: {
code: dept.id,
name: dept.name,
fullName: dept.full_name,
},
})
})
)
console.log('院系数据导入完成')
}
async function main() {
console.log('开始数据库初始化...')
// 插入权限
for (const permName of ALL_PERMISSIONS) {
await prisma.permission.upsert({
where: { name: permName },
update: {},
create: { name: permName },
})
}
// 角色与权限映射
const rolePermissionsMap: Record<string, string[]> = {
系统管理员: ALL_PERMISSIONS,
}
// 插入角色
for (const [roleName, perms] of Object.entries(rolePermissionsMap)) {
await prisma.role.upsert({
where: { name: roleName },
update: {},
create: {
name: roleName,
permissions: {
connect: perms.map((name) => ({ name })),
},
},
})
}
await importDepartments()
// 创建测试用户
const usersToCreate = [
{ id: 'user1', name: '用户甲', status: '在校', deptCode: '00001', roleNames: [] },
{ id: 'sys_admin', name: '系统管理员', status: '在校', deptCode: '00001', roleNames: ['系统管理员'] },
{ id: 'super_admin', password: process.env.SUPER_ADMIN_PASSWORD, name: '超级管理员', status: '在校', deptCode: '00001', roleNames: [], isSuperAdmin: true },
{ id: 'unknown', name: '未知用户', status: '在校', deptCode: '00001', roleNames: [] },
]
for (const u of usersToCreate) {
const password = await bcrypt.hash(u.password ?? '123456', 12)
await prisma.user.upsert({
where: { id: u.id },
update: {
name: u.name,
status: u.status,
deptCode: u.deptCode,
password,
isSuperAdmin: u.isSuperAdmin ?? false,
roles: {
set: u.roleNames.map((name) => ({ name })),
},
},
create: {
id: u.id,
name: u.name,
status: u.status,
deptCode: u.deptCode,
password,
isSuperAdmin: u.isSuperAdmin ?? false,
roles: {
connect: u.roleNames.map((name) => ({ name })),
},
},
})
}
// 插入文件类型(仅开发环境)
const fileTypes = [
{ id: 'COMPONENT_UI', name: 'UI组件', description: '使用"use client"不涉及复杂数据获取专注于UI和交互通用性强' },
{ id: 'COMPONENT_FEATURE', name: '业务组件', description: '使用"use client"不涉及复杂数据获取专注于UI和交互与业务关联性强' },
{ id: 'COMPONENT_PAGE', name: '页面组件', description: 'src/app下的page.tsx文件是页面的主入口' },
{ id: 'COMPONENT_LAYOUT', name: '布局组件', description: '不对应某个特定页面的前端组件,例如定义页面布局的文件' },
{ id: 'COMPONENT_REF', name: '组件关联文件', description: '前端组件相关的其他文件例如定义表格列属性的columns.tsx文件' },
{ id: 'API_TRPC', name: 'tRPC API', description: '基于tRPC的API' },
{ id: 'API_NEXT', name: 'NextJS原生API', description: '直接基于NextJS框架构建的API' },
{ id: 'BACKGROUND_JOB', name: '后台任务', description: '执行后台任务的文件(worker/queue)' },
{ id: 'HOOK', name: 'React Hook', description: '文件名以use开头导出自定义React Hook' },
{ id: 'UTIL', name: '工具函数', description: '提供纯函数、常量等通用工具' },
{ id: 'SCHEMA', name: '数据模式', description: '定义数据长什么样的文件,通常用于保证前后端数据的一致性,对数据进行校验' },
{ id: 'STYLES', name: '样式文件', description: '全局或局部样式文件' },
{ id: 'ASSET', name: '资源文件', description: '图片(包括svg)、视频、音频、文本等' },
{ id: 'TYPE_DEFINITION', name: '类型定义', description: '主要用于导出TypeScript类型和常量' },
{ id: 'GENERATE', name: '自动生成', description: '自动生成的文件' },
{ id: 'SCRIPT', name: '脚本文件', description: '独立于项目单独运行的文件例如prisma/seed.ts' },
{ id: 'FRAMEWORK', name: '框架配置', description: '各种前端库约定俗成的配置文件例如schema.prisma' },
{ id: 'CONFIG', name: '项目配置', description: '项目级别的配置文件通常位于项目根目录下例如package.json' },
{ id: 'OTHER', name: '其他', description: '无法归入以上分类的文件' },
]
for (let index = 0; index < fileTypes.length; index++) {
const fileType = fileTypes[index]
await prisma.devFileType.upsert({
where: { id: fileType.id },
update: {
name: fileType.name,
description: fileType.description,
order: (index + 1) * 10,
},
create: {...fileType, order: (index + 1) * 10},
})
}
console.log('文件类型数据初始化完成')
// 插入依赖包类型(仅开发环境)
const pkgTypes = [
{ id: 'CORE_FRAMEWORK', name: '核心框架', description: '构成应用程序骨架的基础技术决定了项目的基本结构和运行方式例如next、react、react-dom、vue、angular、svelte、remix、solid-js、nuxt、gatsby。' },
{ id: 'UI_INTERACTION', name: 'UI & 交互', description: '负责构建用户界面、处理用户交互和视觉呈现的所有库例如radix-ui/react-xxx、shadcn/ui、@dnd-kit/core、@tanstack/react-table、react-hook-form、lucide-react、framer-motion、antd、mui、chakra-ui、bootstrap、tailwindcss、emotion、styled-components、react-select、react-datepicker、react-toastify、react-icons。' },
{ id: 'API_DATA_COMMS', name: 'API & 数据通信', description: '负责前后端数据交换、API定义和请求例如@trpc/server、@trpc/client、@tanstack/react-query、axios、graphql-request、apollo-client、ws。' },
{ id: 'DATA_LAYER', name: '数据层', description: '负责与数据库、缓存、对象存储等进行交互,例如@prisma/client、ioredis、minio、mongoose、typeorm、sequelize、knex、redis、mongodb、pg、mysql、sqlite3、firebase、supabase。' },
{ id: 'BACKGROUND_JOBS', name: '后台任务', description: '用于处理异步、长时间运行或计划任务例如bullmq、agenda、node-cron、bree、bull、kue、bee-queue、sqs-consumer、rabbitmq、bull-board、bull-arena。' },
{ id: 'SECURITY_AUTH', name: '安全 & 认证', description: '负责用户身份验证、授权和数据加密例如next-auth、bcryptjs、jsonwebtoken、passport、oauth2orize、casl、argon2、express-session、helmet、csrf、@auth0/auth0-react。' },
{ id: 'AI_LLM', name: 'AI & 大模型', description: '专门用于与大型语言模型或其他AI服务进行交互例如ai、@ai-sdk/openai、@ai-sdk/anthropic、openai、@huggingface/inference、langchain、replicate、cohere-ai、stability-sdk、transformers、vertex-ai。' },
{ id: 'UTILITIES', name: '通用工具', description: '提供特定功能的辅助函数库如日期处理、状态管理、验证等例如zod、date-fns、nanoid、lodash、ramda、moment、uuid、joi、yup、clsx、tailwind-merge、deepmerge、numeral、dayjs、chalk、debug、dotenv。' },
{ id: 'NODEJS_CORE', name: 'Node.js核心', description: 'Node.js运行时内置的模块用于底层操作如文件系统、路径处理等例如fs、path、child_process、util、module、os、http、crypto、events、stream、process、net、url、assert。' },
]
for (let index = 0; index < pkgTypes.length; index++) {
const pkgType = pkgTypes[index]
await prisma.devPkgType.upsert({
where: { id: pkgType.id },
update: {
name: pkgType.name,
description: pkgType.description,
order: (index + 1) * 10,
},
create: {...pkgType, order: (index + 1) * 10},
})
}
console.log('依赖包类型数据初始化完成')
console.log('数据库初始化完成')
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

File diff suppressed because one or more lines are too long

BIN
public/pku_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/pku_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/pku_logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,122 @@
'use client'
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
// 登录表单验证 schema
const loginSchema = z.object({
id: z.string().min(1, "请输入用户ID"),
password: z.string().min(1, "请输入密码"),
})
type LoginFormData = z.infer<typeof loginSchema>
export default function LoginPage() {
const router = useRouter()
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
id: "",
password: "",
},
})
const onSubmit = async (data: LoginFormData) => {
try {
const result = await signIn("credentials", {
id: data.id,
password: data.password,
redirect: false,
})
if (result?.error) {
form.setError("root", {
type: "manual",
message: "用户ID或密码错误"
})
} else if (result?.ok) {
// 登录成功,重定向到首页
router.push("/")
router.refresh()
}
} catch (error) {
form.setError("root", {
type: "manual",
message: "登录失败,请重试"
})
console.error("Login error:", error)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center"></CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input
type="text"
placeholder="请输入用户ID"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入密码"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<div className="text-sm text-red-600 text-center">
{form.formState.errors.root.message}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "登录中..." : "登录"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { notFound } from 'next/navigation'
export default function NotFoundCatchAll() {
notFound()
}

View File

@@ -0,0 +1,9 @@
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
export default function ArchLayout({
children,
}: {
children: React.ReactNode;
}) {
return <SubMenuLayout parentHref="/dev/arch">{children}</SubMenuLayout>;
}

View File

@@ -0,0 +1,189 @@
'use client'
import React from 'react'
import { trpc } from '@/lib/trpc'
import { Button } from '@/components/ui/button'
import { FileSearch } from 'lucide-react'
import { toast } from 'sonner'
import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog'
import type { AnalyzePackagesProgress } from '@/server/queues'
/**
* 扩展的分析进度类型
*/
interface AnalyzeProgress extends BaseTaskProgress, AnalyzePackagesProgress {}
interface PackageAnalyzeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
jobId: string | null
onAnalyzeCompleted: () => void
}
interface PackageAnalyzeTriggerProps {
onStartAnalyze: () => void
isStarting: boolean
}
/**
* 依赖包分析触发器按钮
*/
export function PackageAnalyzeTrigger({
onStartAnalyze,
isStarting
}: PackageAnalyzeTriggerProps) {
return (
<Button
variant="default"
onClick={onStartAnalyze}
disabled={isStarting}
>
<FileSearch className="mr-2 h-4 w-4" />
{isStarting ? '启动中...' : '依赖包分析'}
</Button>
)
}
/**
* 依赖包分析进度对话框
*/
export function PackageAnalyzeDialog({
open,
onOpenChange,
jobId,
onAnalyzeCompleted
}: PackageAnalyzeDialogProps) {
// 停止分析任务 mutation
const cancelMutation = trpc.devArch!.cancelAnalyzePackagesJob.useMutation({
onSuccess: () => {
toast.success('已发送停止请求')
},
onError: (error) => {
toast.error(error.message || '停止任务失败')
},
})
// 停止任务
const handleCancelTask = async (taskJobId: string) => {
await cancelMutation.mutateAsync({ jobId: taskJobId })
}
// 自定义状态消息渲染
const renderStatusMessage = (progress: AnalyzeProgress) => {
if (progress.state === 'waiting') {
return '任务等待中...'
} else if (progress.state === 'active') {
if (progress.currentPackage) {
return `正在分析: ${progress.currentPackage}`
}
return '正在分析依赖包...'
} else if (progress.state === 'completed') {
const successCount = (progress.analyzedPackages || 0) - (progress.failedPackages || 0)
const failedCount = progress.failedPackages || 0
const skippedCount = progress.skippedPackages || 0
const parts = [`成功 ${successCount}`]
if (failedCount > 0) {
parts.push(`失败 ${failedCount}`)
}
if (skippedCount > 0) {
parts.push(`跳过 ${skippedCount}`)
}
parts.push(`${progress.totalPackages || 0} 个依赖包`)
return `分析完成!${parts.join('')}`
} else if (progress.state === 'failed') {
return progress.error || '分析失败'
}
return ''
}
// 自定义详细信息渲染
const renderDetails = (progress: AnalyzeProgress) => {
if (progress.totalPackages === undefined && progress.analyzedPackages === undefined) {
return null
}
const successCount = (progress.analyzedPackages || 0) - (progress.failedPackages || 0)
return (
<div className="space-y-4">
{/* 进度统计 */}
<div className="grid grid-cols-2 gap-4 text-sm">
{progress.totalPackages !== undefined && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium">{progress.totalPackages}</span>
</div>
)}
{progress.analyzedPackages !== undefined && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium">{progress.analyzedPackages}</span>
</div>
)}
{successCount > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-green-600">{successCount}</span>
</div>
)}
{progress.failedPackages !== undefined && progress.failedPackages > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-red-600">{progress.failedPackages}</span>
</div>
)}
{progress.skippedPackages !== undefined && progress.skippedPackages > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-blue-600">{progress.skippedPackages}</span>
</div>
)}
</div>
{/* 当前处理的依赖包 */}
{progress.currentPackage && progress.state === 'active' && (
<div className="rounded-md bg-muted p-3 text-sm">
<div className="text-muted-foreground mb-1"></div>
<div className="font-mono text-xs break-all">{progress.currentPackage}</div>
</div>
)}
{/* 最近的错误信息 */}
{progress.recentErrors && progress.recentErrors.length > 0 && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm">
<div className="text-red-800 font-medium mb-2"> (10)</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{progress.recentErrors.map((err, index) => (
<div key={index} className="text-xs">
<div className="font-mono text-red-700 break-all">{err.packageName}</div>
<div className="text-red-600 mt-1">{err.error}</div>
{index < progress.recentErrors!.length - 1 && (
<div className="border-t border-red-200 mt-2" />
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
return (
<TaskDialog<AnalyzeProgress>
open={open}
onOpenChange={onOpenChange}
useSubscription={trpc.jobs.subscribeAnalyzePackagesProgress.useSubscription}
jobId={jobId}
title="依赖包分析进度"
description="正在使用AI分析项目依赖包请稍候..."
onCancelTask={handleCancelTask}
onTaskCompleted={onAnalyzeCompleted}
isCancelling={cancelMutation.isPending}
renderStatusMessage={renderStatusMessage}
renderDetails={renderDetails}
/>
)
}

View File

@@ -0,0 +1,209 @@
'use client'
import * as React from 'react'
import { Package, Link as LinkIcon, Code2, FileCode, Globe, Code } from 'lucide-react'
import {
DetailSheet,
DetailHeader,
DetailSection,
DetailField,
DetailFieldGroup,
DetailList,
DetailCopyable,
} from '@/components/data-details'
import { Badge } from '@/components/ui/badge'
import { formatDate } from '@/lib/format'
import { SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
import type { PackageData } from "@/server/routers/dev/arch";
export interface PackageDetailSheetProps {
pkg: PackageData | null
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* 依赖包详情展示Sheet
* 使用通用详情展示框架展示DevAnalyzedPkg对象的完整信息
*/
export function PackageDetailSheet({
pkg,
open,
onOpenChange,
}: PackageDetailSheetProps) {
if (!pkg) return null
// 处理关联文件列表
const relatedFileItems = (pkg.relatedFiles || []).map((filePath, index) => ({
id: `file-${index}`,
label: filePath,
icon: FileCode,
}))
// 解析主要使用模式(按行分割)
const usagePatterns = pkg.primaryUsagePattern
.split('\n')
.filter(line => line.trim())
.map((pattern, index) => ({
id: `pattern-${index}`,
label: pattern.trim(),
}))
return (
<DetailSheet
open={open}
onOpenChange={onOpenChange}
width="xl"
header={
<SheetTitle title={pkg.name}>
<DetailHeader
title={pkg.name}
subtitle={
<>
<Badge variant="primary" appearance="light" className="text-xs">
v{pkg.version}
</Badge>
<Badge variant="outline" className="text-xs">
{pkg.pkgType.name}
</Badge>
</>
}
icon={<Package className="h-6 w-6" />}
/>
</SheetTitle>
}
description={<VisuallyHidden><SheetDescription>{pkg.description}</SheetDescription></VisuallyHidden>}
>
{/* 基本信息 */}
<DetailSection title="基本信息" icon={Package}>
<DetailFieldGroup columns={2}>
<DetailField
label="包名"
value={<DetailCopyable value={pkg.name} />}
/>
<DetailField
label="版本号"
value={
<Badge variant="primary" appearance="light">
v{pkg.version}
</Badge>
}
/>
<DetailField
label="包类型"
value={
<div className="space-y-1">
<Badge variant="outline">{pkg.pkgType.name}</Badge>
</div>
}
/>
<DetailField
label="发布时间"
value={formatDate(pkg.modifiedAt, "PPP")}
/>
<DetailField
label="最后分析时间"
value={formatDate(pkg.lastAnalyzedAt, "PPP HH:mm:ss")}
/>
<DetailField
label="创建时间"
value={formatDate(pkg.createdAt, "PPP HH:mm:ss")}
/>
</DetailFieldGroup>
</DetailSection>
{/* 官方描述 */}
<DetailSection title="官方描述" icon={FileCode}>
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
{pkg.description}
</div>
</DetailSection>
{/* 项目中的角色 */}
<DetailSection title="项目中的角色" icon={Code2}>
<div className="space-y-4">
<DetailField
label="核心功能"
value={
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
{pkg.projectRoleSummary}
</div>
}
/>
</div>
</DetailSection>
{/* 主要使用模式 */}
{usagePatterns.length > 0 && (
<DetailSection
title="主要使用模式"
description={`${usagePatterns.length} 种使用模式`}
icon={Code2}
collapsible
defaultOpen={true}
>
<DetailList
items={usagePatterns}
maxHeight="300px"
/>
</DetailSection>
)}
{/* 关联文件 */}
{relatedFileItems.length > 0 && (
<DetailSection
title="关联文件"
description={`${relatedFileItems.length} 个文件直接或有可能间接使用了此包`}
icon={FileCode}
collapsible
defaultOpen={false}
>
<DetailList
items={relatedFileItems}
searchable
maxHeight="400px"
/>
</DetailSection>
)}
{/* 链接信息 */}
<DetailSection title="链接信息" icon={LinkIcon}>
<DetailFieldGroup columns={1}>
{pkg.homepage && (
<DetailField
label="主页"
value={
<a
href={pkg.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary hover:text-primary/80 transition-colors"
>
<Globe className="size-4" />
<span className="text-sm underline">{pkg.homepage}</span>
</a>
}
/>
)}
{pkg.repositoryUrl && (
<DetailField
label="仓库地址"
value={
<a
href={pkg.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary hover:text-primary/80 transition-colors"
>
<Code className="size-4" />
<span className="text-sm underline break-all">{pkg.repositoryUrl}</span>
</a>
}
/>
)}
</DetailFieldGroup>
</DetailSection>
</DetailSheet>
)
}

View File

@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Package, FileCode, Code, FileClock } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { SearchInput } from "@/components/common/search-input";
import { ResponsiveTabs, type ResponsiveTabItem } from "@/components/common/responsive-tabs";
import { trpc } from "@/lib/trpc";
import { Skeleton } from "@/components/ui/skeleton";
import { PackageAnalyzeDialog, PackageAnalyzeTrigger } from "./components/PackageAnalyzeDialog";
import { PackageDetailSheet } from "./components/PackageDetailSheet";
import { toast } from 'sonner'
import type { PackageData } from "@/server/routers/dev/arch";
import { formatDate } from "@/lib/format";
// 依赖包卡片组件
function PackageCard({ pkg, onClick }: { pkg: PackageData; onClick: () => void }) {
return (
<Card
className="shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20 flex flex-col gap-4 cursor-pointer"
onClick={onClick}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2 min-w-0">
<CardTitle className="flex items-center gap-2 text-xl mb-1.5 min-w-0">
<Package className="size-4.5 shrink-0 text-primary" />
{pkg.homepage ? (
<a
href={pkg.homepage}
target="_blank"
rel="noopener noreferrer"
className="truncate font-semibold bg-gradient-to-r from-purple-600 via-violet-600 to-indigo-600 dark:from-purple-400 dark:via-violet-400 dark:to-indigo-400 bg-clip-text text-transparent hover:opacity-80 transition-opacity"
>
{pkg.name}
</a>
) : (
<span className="truncate font-semibold bg-gradient-to-r from-purple-600 via-violet-600 to-indigo-600 dark:from-purple-400 dark:via-violet-400 dark:to-indigo-400 bg-clip-text text-transparent">
{pkg.name}
</span>
)}
{pkg.repositoryUrl && (
<a
href={pkg.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors font-medium"
>
<Code className="size-3" />
</a>
)}
</CardTitle>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="primary" size="sm" appearance="light" className="shrink-0 text-xs cursor-help">
v{pkg.version}
</Badge>
</TooltipTrigger>
<TooltipContent variant="light">
<div className="text-xs">
<div className="font-medium">{pkg.name} v{pkg.version}</div>
<div className="text-muted-foreground"> {formatDate(pkg.modifiedAt)}</div>
</div>
</TooltipContent>
</Tooltip>
</div>
<CardDescription className="line-clamp-3 text-xs leading-snug">
{pkg.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0 flex flex-col flex-1">
<div className="bg-muted/30 rounded-md p-2.5 mb-3">
<div className="text-xs font-medium text-foreground mb-1 flex items-center gap-1.5">
<span className="size-1 rounded-full bg-primary" />
</div>
<p className="text-xs leading-relaxed text-muted-foreground">{pkg.projectRoleSummary}</p>
</div>
<div className="flex items-center justify-between pt-2 border-t mt-auto">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<FileCode className="size-3 text-primary" />
<span className="font-medium">{pkg.relatedFileCount}</span>
<span></span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-muted-foreground cursor-help">
<FileClock className="size-3" />
<span>{formatDate(pkg.lastAnalyzedAt)}</span>
</div>
</TooltipTrigger>
<TooltipContent variant="light">
<div className="text-xs"></div>
</TooltipContent>
</Tooltip>
</div>
</CardContent>
</Card>
);
}
export default function ArchPackagePage() {
const [searchQuery, setSearchQuery] = useState('');
// 用于刷新数据的 utils
const utils = trpc.useUtils()
// 获取所有包类型
const { data: pkgTypes, isLoading: isLoadingTypes } = trpc.devArch!.getAllPkgTypes.useQuery();
// 获取所有依赖包数据
const { data: packagesByType, isLoading: isLoadingPackages } = trpc.devArch!.getAllPackages.useQuery();
// 刷新依赖包列表
const handleRefreshPackages = useCallback(() => {
utils.devArch!.getAllPackages.invalidate()
utils.devArch!.getAllPkgTypes.invalidate()
}, [utils])
// 使用第一个包类型作为默认激活标签
const [activeTab, setActiveTab] = useState<string>('');
// 分析对话框状态
const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false)
const [analyzeJobId, setAnalyzeJobId] = useState<string | null>(null)
// 详情Sheet状态
const [selectedPackage, setSelectedPackage] = useState<PackageData | null>(null)
const [isDetailSheetOpen, setIsDetailSheetOpen] = useState(false)
// 处理卡片点击
const handleCardClick = useCallback((pkg: PackageData) => {
setSelectedPackage(pkg)
setIsDetailSheetOpen(true)
}, [])
// 启动依赖包分析 mutation
const analyzeMutation = trpc.devArch!.startAnalyzePackages.useMutation({
onSuccess: (data) => {
// 打开进度对话框
setAnalyzeJobId(String(data.jobId))
setIsAnalyzeDialogOpen(true)
},
onError: (error) => {
toast.error(error.message || '启动依赖包分析失败')
},
})
// 启动分析
const handleStartAnalyze = () => {
analyzeMutation.mutate()
}
// 当包类型加载完成后,设置默认激活标签
useEffect(() => {
if (pkgTypes && pkgTypes.length > 0 && !activeTab) {
setActiveTab(pkgTypes[0].id);
}
}, [pkgTypes, activeTab]);
const isLoading = isLoadingTypes || isLoadingPackages;
// 按优先级搜索过滤name > description > projectRoleSummary > primaryUsagePattern
const getFilteredPackages = useCallback((typeId: string) => {
const packages = packagesByType?.[typeId] || [];
if (!searchQuery) return packages;
const query = searchQuery.toLowerCase();
// 计算每个包的匹配优先级
const packagesWithPriority = packages.map((pkg) => {
let priority = 0;
if (pkg.name.toLowerCase().includes(query)) {
priority = 4;
}
else if (pkg.description.toLowerCase().includes(query)) {
priority = 3;
}
else if (pkg.projectRoleSummary.toLowerCase().includes(query)) {
priority = 2;
}
else if (pkg.primaryUsagePattern.toLowerCase().includes(query)) {
priority = 1;
}
return { pkg, priority };
});
// 过滤出有匹配的包,并按优先级排序
return packagesWithPriority
.filter(({ priority }) => priority > 0)
.sort((a, b) => b.priority - a.priority)
.map(({ pkg }) => pkg);
}, [packagesByType, searchQuery]);
// 将包类型转换为标签项
const tabItems: ResponsiveTabItem[] = pkgTypes?.map((type) => ({
id: type.id,
name: type.name,
description: type.description,
count: packagesByType?.[type.id]?.length || 0,
})) || [];
// 仅当pkgTypes完成加载且为空时才显示"暂无依赖包数据"
if (!isLoading && (!pkgTypes || pkgTypes.length === 0)) {
return (
<div className="text-center py-12 text-muted-foreground">
<Package className="size-10 mx-auto mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
);
}
return (
<>
{isLoading ? (
<ResponsiveTabs.Skeleton className="pb-6">
<Skeleton className="h-10 w-full" />
<div className="grid gap-4 md:grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-48 w-full rounded-lg" />
))}
</div>
</ResponsiveTabs.Skeleton>
) : (
<ResponsiveTabs
items={tabItems}
value={activeTab}
onValueChange={setActiveTab}
className="pb-6"
showIdBadge
showCountBadge
>
{tabItems.map((item) => {
const filteredPackages = getFilteredPackages(item.id);
return (
<ResponsiveTabs.Content key={item.id} value={item.id}>
<div className="space-y-4">
{/* 搜索栏和操作按钮 */}
<div className="flex items-center gap-3">
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="搜索依赖包..."
className="w-80"
/>
<div className="flex-1" />
<PackageAnalyzeTrigger
onStartAnalyze={handleStartAnalyze}
isStarting={analyzeMutation.isPending}
/>
</div>
{/* 包列表 */}
{filteredPackages.length > 0 ? (
<div className="grid gap-4 md:grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4">
{filteredPackages.map((pkg) => (
<PackageCard
key={pkg.name}
pkg={pkg}
onClick={() => handleCardClick(pkg)}
/>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Package className="size-10 mx-auto mb-2 opacity-20" />
<p className="text-sm">
{searchQuery ? '未找到匹配的依赖包' : '暂无依赖包数据'}
</p>
</div>
)}
</div>
</ResponsiveTabs.Content>
);
})}
</ResponsiveTabs>
)}
{/* 依赖包分析进度对话框 */}
<PackageAnalyzeDialog
open={isAnalyzeDialogOpen}
onOpenChange={setIsAnalyzeDialogOpen}
jobId={analyzeJobId}
onAnalyzeCompleted={handleRefreshPackages}
/>
{/* 依赖包详情Sheet */}
<PackageDetailSheet
pkg={selectedPackage}
open={isDetailSheetOpen}
onOpenChange={setIsDetailSheetOpen}
/>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
export default function ArchPage() {
return <SubMenuRedirect parentHref="/dev/arch" />;
}

View File

@@ -0,0 +1,68 @@
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.623 0.214 259.815);
--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);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--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.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
}

View File

@@ -0,0 +1,387 @@
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatDate } from '@/lib/format'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { Eye } from 'lucide-react'
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
import { Option } from '@/types/data-table'
import { SourceFileIcon } from '@/components/icons/code-lang'
export interface DevAnalyzedFileColumnsOptions {
fileTypes: Array<Option>
commitIds: Array<Option>
tagsStats: Array<Option>
pkgDependencyStats: Array<Option>
onViewDetail?: (file: DevAnalyzedFile) => void
}
// 创建文件表格列定义
export const createDevAnalyzedFileColumns = (
options: DevAnalyzedFileColumnsOptions
): ColumnDef<DevAnalyzedFile>[] => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
size: 32,
enableSorting: false,
enableHiding: false,
},
{
id: 'path',
accessorKey: 'path',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="文件路径" />
),
cell: ({ row }) => (
<div className="line-clamp-4 text-sm whitespace-normal break-words font-mono text-xs" title={row.original.path}>
{row.original.path}
</div>
),
size: 200,
enableColumnFilter: false,
enableSorting: true,
meta: {
label: '文件路径',
},
},
{
id: 'fileName',
accessorKey: 'fileName',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="文件名" />
),
cell: ({ row }) => {
const fileName = row.original.fileName
const extension = fileName.split('.').pop() || ''
return (
<div className="flex items-center gap-2">
<SourceFileIcon extension={extension} className="shrink-0" color="currentColor" />
<div className="text-md line-clamp-2 whitespace-normal break-words">{fileName}</div>
</div>
)
},
size: 180,
enableColumnFilter: true,
meta: {
label: '文件名',
filter: {
placeholder: '请输入文件路径或文件名',
variant: 'text',
}
},
},
{
id: 'commitId',
accessorKey: 'commitId',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Commit ID" />
),
cell: ({ row }) => {
const commitId = row.original.commitId
if (!commitId) {
return <div className="text-sm text-muted-foreground"></div>
}
const isModified = commitId.endsWith('*')
return (
<div className="flex items-center gap-1">
<code className="text-sm font-mono px-1.5 py-0.5 rounded">
{isModified ? (
<>
{commitId.slice(0, -1)}
<span className="text-red-500">*</span>
</>
) : (
commitId
)}
</code>
</div>
)
},
size: 120,
enableColumnFilter: true,
enableSorting: true,
meta: {
label: 'Commit ID',
filter: {
variant: 'select',
options: options.commitIds,
}
},
},
{
id: 'fileTypeId',
accessorKey: 'fileTypeId',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="文件类型" />
),
cell: ({ row }) => {
const fileTypeName = row.original.fileType?.name || row.original.fileTypeId
return (
<Badge variant="outline">
{fileTypeName}
</Badge>
)
},
size: 120,
enableColumnFilter: true,
meta: {
label: '文件类型',
filter: {
variant: 'select',
options: options.fileTypes,
}
},
},
{
id: 'summary',
accessorKey: 'summary',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="功能摘要" />
),
cell: ({ row }) => (
<div
className="line-clamp-4 text-sm whitespace-normal break-words"
title={row.original.summary}
>
{row.original.summary}
</div>
),
size: 200,
enableColumnFilter: true,
meta: {
label: '功能摘要',
filter: {
placeholder: '搜索功能摘要或详细描述...',
variant: 'text',
}
},
},
{
id: 'description',
accessorKey: 'description',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="详细描述" />
),
cell: ({ row }) => (
<div
className="line-clamp-4 text-sm whitespace-normal break-words"
title={row.original.description}
>
{row.original.description}
</div>
),
size: 400,
enableColumnFilter: true,
meta: {
label: '详细描述',
},
},
{
id: 'exportedMembers',
accessorKey: 'exportedMembers',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="导出成员" />
),
cell: ({ row }) => {
const members = row.original.exportedMembers as Array<{ name: string; type: string }> | null
if (!members || members.length === 0) {
return <div className="text-xs text-muted-foreground"></div>
}
return (
<div className="flex flex-wrap gap-1">
{members.map((member, index) => (
<Badge key={index} variant="outline" className="text-xs">
{member.name}
</Badge>
))}
</div>
)
},
meta: {
label: '导出成员'
},
size: 240,
enableColumnFilter: false,
enableSorting: false,
},
{
id: 'dependencies',
accessorKey: 'dependencies',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="依赖文件" />
),
cell: ({ row }) => {
const deps = row.original.dependencies || []
if (deps.length === 0) {
return <div className="text-xs text-muted-foreground"></div>
}
return (
<div className="flex flex-wrap gap-1">
{deps.map((dep, index) => (
<Badge
key={index}
variant="secondary"
appearance="light"
className="text-xs"
title={dep.usageDescription || dep.targetFilePath}
>
{dep.targetFilePath.split('/').pop()}
</Badge>
))}
</div>
)
},
meta: {
label: '依赖文件'
},
size: 200,
enableColumnFilter: false,
enableSorting: false,
},
{
id: 'pkgDependencies',
accessorKey: 'pkgDependencies',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="包依赖" />
),
cell: ({ row }) => {
const pkgDeps = row.original.pkgDependencies || []
if (pkgDeps.length === 0) {
return <div className="text-xs text-muted-foreground"></div>
}
return (
<div className="flex flex-wrap gap-1">
{pkgDeps.map((dep, index) => (
<Badge
key={index}
variant="secondary"
appearance="outline"
className="text-xs"
title={dep.usageDescription || dep.packageName}
>
{dep.packageName}
</Badge>
))}
</div>
)
},
meta: {
label: '包依赖',
filter: {
variant: 'multiSelect',
options: options.pkgDependencyStats,
}
},
size: 200,
enableColumnFilter: true,
enableSorting: false,
},
{
id: 'tags',
accessorKey: 'tags',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="标签" />
),
cell: ({ row }) => {
const tags = row.original.tags || []
return (
<div className="flex flex-wrap gap-1">
{tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)
},
size: 200,
enableColumnFilter: true,
enableSorting: false,
meta: {
label: '标签',
filter: {
variant: 'multiSelect',
options: options.tagsStats,
}
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="创建时间" />
),
cell: ({ row }) => (
<div className="text-sm">
{formatDate(row.original.createdAt)}
</div>
),
size: 120,
enableColumnFilter: true,
meta: {
label: '创建时间',
filter: {
variant: 'dateRange',
}
},
},
{
id: 'lastAnalyzedAt',
accessorKey: 'lastAnalyzedAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="分析时间" />
),
cell: ({ row }) => (
<div className="text-sm">
{formatDate(row.original.lastAnalyzedAt)}
</div>
),
size: 120,
enableColumnFilter: true,
meta: {
label: '分析时间',
filter: {
variant: 'dateRange',
}
},
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<Button
variant="ghost"
size="sm"
onClick={() => options.onViewDetail?.(row.original)}
>
<Eye className="h-4 w-4 mr-1" />
</Button>
),
size: 120,
enableSorting: false,
enableHiding: false,
},
]

View File

@@ -0,0 +1,192 @@
'use client'
import React, { useState } from 'react'
import { trpc } from '@/lib/trpc'
import { Button } from '@/components/ui/button'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import { FileSearch } from 'lucide-react'
import { toast } from 'sonner'
import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog'
import type { AnalyzeFilesProgress } from '@/server/queues'
/**
* 扩展的分析进度类型
*/
interface AnalyzeProgress extends BaseTaskProgress, AnalyzeFilesProgress {}
interface FileAnalyzeDialogProps {
onAnalyzeCompleted: () => void
}
export function FileAnalyzeDialog({ onAnalyzeCompleted }: FileAnalyzeDialogProps) {
const [isProgressDialogOpen, setIsProgressDialogOpen] = useState(false)
const [jobId, setJobId] = useState<string | null>(null)
// 启动文件分析 mutation
const analyzeMutation = trpc.devFile!.startAnalyzeFiles.useMutation({
onSuccess: (data) => {
// 打开进度对话框
setJobId(String(data.jobId))
setIsProgressDialogOpen(true)
},
onError: (error) => {
toast.error(error.message || '启动文件分析失败')
},
})
// 停止分析任务 mutation
const cancelMutation = trpc.devFile!.cancelAnalyzeFilesJob.useMutation({
onSuccess: () => {
toast.success('已发送停止请求')
},
onError: (error) => {
toast.error(error.message || '停止任务失败')
},
})
// 启动分析
const handleStartAnalyze = () => {
analyzeMutation.mutate()
}
// 停止任务
const handleCancelTask = async (taskJobId: string) => {
await cancelMutation.mutateAsync({ jobId: taskJobId })
}
// 自定义状态消息渲染
const renderStatusMessage = (progress: AnalyzeProgress) => {
if (progress.state === 'waiting') {
return '任务等待中...'
} else if (progress.state === 'active') {
if (progress.currentFile) {
return `正在分析: ${progress.currentFile}`
}
return '正在分析文件...'
} else if (progress.state === 'completed') {
const successCount = (progress.analyzedFiles || 0) - (progress.failedFiles || 0)
const failedCount = progress.failedFiles || 0
const skippedCount = progress.skippedFiles || 0
const parts = [`成功 ${successCount}`]
if (failedCount > 0) {
parts.push(`失败 ${failedCount}`)
}
if (skippedCount > 0) {
parts.push(`跳过 ${skippedCount}`)
}
parts.push(`${progress.totalFiles || 0} 个文件`)
return `分析完成!${parts.join('')}`
} else if (progress.state === 'failed') {
return progress.error || '分析失败'
}
return ''
}
// 自定义详细信息渲染
const renderDetails = (progress: AnalyzeProgress) => {
if (progress.totalFiles === undefined && progress.analyzedFiles === undefined) {
return null
}
const successCount = (progress.analyzedFiles || 0) - (progress.failedFiles || 0)
return (
<div className="space-y-4">
{/* 进度统计 */}
<div className="grid grid-cols-2 gap-4 text-sm">
{progress.totalFiles !== undefined && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium">{progress.totalFiles}</span>
</div>
)}
{progress.analyzedFiles !== undefined && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium">{progress.analyzedFiles}</span>
</div>
)}
{successCount > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-green-600">{successCount}</span>
</div>
)}
{progress.failedFiles !== undefined && progress.failedFiles > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-red-600">{progress.failedFiles}</span>
</div>
)}
{progress.skippedFiles !== undefined && progress.skippedFiles > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-blue-600">{progress.skippedFiles}</span>
</div>
)}
</div>
{/* 当前处理的文件 */}
{progress.currentFile && progress.state === 'active' && (
<div className="rounded-md bg-muted p-3 text-sm">
<div className="text-muted-foreground mb-1"></div>
<div className="font-mono text-xs break-all">{progress.currentFile}</div>
</div>
)}
{/* 最近的错误信息 */}
{progress.recentErrors && progress.recentErrors.length > 0 && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm">
<div className="text-red-800 font-medium mb-2"> (10)</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{progress.recentErrors.map((err, index) => (
<div key={index} className="text-xs">
<div className="font-mono text-red-700 break-all">{err.filePath}</div>
<div className="text-red-600 mt-1">{err.error}</div>
{index < progress.recentErrors!.length - 1 && (
<div className="border-t border-red-200 mt-2" />
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
return (
<>
{/* 启动按钮 */}
<Dialog>
<DialogTrigger asChild>
<Button
variant="default"
onClick={handleStartAnalyze}
disabled={analyzeMutation.isPending}
>
<FileSearch className="mr-2 h-4 w-4" />
{analyzeMutation.isPending ? '启动中...' : '文件分析'}
</Button>
</DialogTrigger>
</Dialog>
{/* 进度显示对话框 */}
<TaskDialog<AnalyzeProgress>
open={isProgressDialogOpen}
onOpenChange={setIsProgressDialogOpen}
useSubscription={trpc.jobs.subscribeAnalyzeFilesProgress.useSubscription}
jobId={jobId}
title="文件分析进度"
description="正在使用AI分析项目文件请稍候..."
onCancelTask={handleCancelTask}
onTaskCompleted={onAnalyzeCompleted}
isCancelling={cancelMutation.isPending}
renderStatusMessage={renderStatusMessage}
renderDetails={renderDetails}
/>
</>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import React, { useMemo } from 'react'
import { Node, Handle, Position } from '@xyflow/react'
import { Badge } from '@/components/ui/badge'
import { FileCode, FileX } from 'lucide-react'
import { AdaptiveGraph } from '@/components/features/adaptive-graph'
// 节点数据类型
interface GraphNodeData {
id: string
path: string
fileName: string
fileTypeId: string
fileTypeName: string
summary: string | null
dependencyCount: number
isDeleted: boolean
}
// 组件 Props
interface FileDependencyGraphProps {
nodes: GraphNodeData[]
edges: Array<{ source: string; target: string; label?: string }>
onNodeClick?: (node: GraphNodeData) => void
}
// 自定义节点组件
const CustomNode = ({ data }: { data: GraphNodeData & { isHighlighted?: boolean; isDimmed?: boolean } }) => {
const bgColor = data.isDeleted
? 'bg-red-50 dark:bg-red-950/20'
: data.isHighlighted
? 'bg-blue-50 dark:bg-blue-950/50'
: data.isDimmed
? 'bg-gray-50 dark:bg-gray-900/30'
: 'bg-white dark:bg-gray-800'
const borderColor = data.isDeleted
? 'border-red-300 dark:border-red-700'
: data.isHighlighted
? 'border-blue-500 dark:border-blue-400'
: 'border-gray-200 dark:border-gray-700'
const opacity = data.isDimmed ? 'opacity-40' : 'opacity-100'
return (
<div
className={`px-3 py-2 rounded-lg border-2 shadow-sm transition-all ${bgColor} ${borderColor} ${opacity} min-w-[180px] max-w-[220px]`}
>
{/* Handle 组件用于连接边没有Handle看不见连边!opacity-0用来隐藏卡片上用来连接的小点 */}
<Handle type="target" position={Position.Top} className="!opacity-0" />
<div className="flex items-start gap-2">
{data.isDeleted ? (
<FileX className="h-4 w-4 text-red-500 flex-shrink-0 mt-0.5" />
) : (
<FileCode className="h-4 w-4 text-blue-500 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate" title={data.fileName}>
{data.fileName}
</div>
<div className="text-xs text-muted-foreground truncate" title={data.path}>
{data.path}
</div>
<div className="flex items-center gap-1 mt-1">
<Badge variant="outline" className="text-xs px-1 py-0">
{data.fileTypeName}
</Badge>
{data.dependencyCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0">
{data.dependencyCount}
</Badge>
)}
{data.isDeleted && (
<Badge variant="destructive" className="text-xs px-1 py-0">
</Badge>
)}
</div>
</div>
</div>
{/* Handle 组件用于连接边没有Handle看不见连边!opacity-0用来隐藏卡片上用来连接的小点 */}
<Handle type="source" position={Position.Bottom} className="!opacity-0" />
</div>
)
}
// 节点类型定义
const nodeTypes = {
custom: CustomNode,
}
export function FileDependencyGraph({ nodes: rawNodes, edges: rawEdges, onNodeClick }: FileDependencyGraphProps) {
// 构建依赖关系映射
const dependencyMap = useMemo(() => {
const map = new Map<string, Set<string>>()
const reverseDependencyMap = new Map<string, Set<string>>()
rawEdges.forEach((edge) => {
// 正向依赖source 依赖 target
if (!map.has(edge.source)) {
map.set(edge.source, new Set())
}
map.get(edge.source)!.add(edge.target)
// 反向依赖target 被 source 依赖
if (!reverseDependencyMap.has(edge.target)) {
reverseDependencyMap.set(edge.target, new Set())
}
reverseDependencyMap.get(edge.target)!.add(edge.source)
})
return { dependencies: map, reverseDependencies: reverseDependencyMap }
}, [rawEdges])
// 过滤函数:根据搜索查询返回过滤后的节点和高亮节点
const handleFilter = useMemo(
() => (nodes: GraphNodeData[], query: string) => {
if (!query.trim()) {
return {
filteredNodeIds: new Set(nodes.map((n) => n.id)),
highlightedNodeIds: new Set<string>(),
}
}
const lowerQuery = query.toLowerCase()
const matchedNodes = nodes.filter(
(node) =>
node.fileName.toLowerCase().includes(lowerQuery) ||
node.path.toLowerCase().includes(lowerQuery) ||
node.summary?.toLowerCase().includes(lowerQuery)
)
// 包含匹配的节点及其依赖和被依赖的节点
const resultSet = new Set<string>()
matchedNodes.forEach((node) => {
resultSet.add(node.id)
// 添加该节点依赖的节点
const deps = dependencyMap.dependencies.get(node.id)
if (deps) {
deps.forEach((depId) => resultSet.add(depId))
}
// 添加依赖该节点的节点
const reverseDeps = dependencyMap.reverseDependencies.get(node.id)
if (reverseDeps) {
reverseDeps.forEach((depId) => resultSet.add(depId))
}
})
return {
filteredNodeIds: resultSet,
highlightedNodeIds: new Set(matchedNodes.map((n) => n.id)),
}
},
[dependencyMap]
)
// 节点转换函数
const transformNode = (
node: GraphNodeData,
options: { isHighlighted: boolean; isDimmed: boolean }
): Node => ({
id: node.id,
type: 'custom',
data: {
...node,
isHighlighted: options.isHighlighted,
isDimmed: options.isDimmed,
},
position: { x: 0, y: 0 }, // 将由布局算法设置
})
// MiniMap 节点颜色函数
const getNodeColor = (node: Node) => {
const data = node.data as unknown as GraphNodeData & { isHighlighted?: boolean; isDeleted?: boolean }
if (data.isDeleted) return '#fca5a5'
if (data.isHighlighted) return '#60a5fa'
return '#cbd5e1'
}
// 统计信息渲染
const renderStats = (filteredCount: number, totalCount: number, matchedCount: number) => (
<div className="mt-2 text-xs text-muted-foreground">
{filteredCount} / {totalCount}
{matchedCount > 0 && ` (${matchedCount} 个匹配)`}
</div>
)
return (
<AdaptiveGraph
nodes={rawNodes}
edges={rawEdges}
nodeTypes={nodeTypes}
onFilter={handleFilter}
transformNode={transformNode}
getNodeColor={getNodeColor}
onNodeClick={onNodeClick}
searchPlaceholder="搜索文件名、路径或摘要..."
renderStats={renderStats}
className="h-[800px] max-h-[calc(100vh-12rem)]"
reactFlowProps={{
nodesDraggable: true,
nodesConnectable: false,
elementsSelectable: true,
}}
/>
)
}

View File

@@ -0,0 +1,432 @@
'use client';
import React from 'react';
import { FileCode, Tag, Link as LinkIcon, Code2, Package, GitCommit } from 'lucide-react';
import {
DetailSection,
DetailField,
DetailFieldGroup,
DetailBadgeList,
DetailList,
DetailCodeBlock,
DetailCopyable,
Timeline,
TimelineItem,
TimelineConnector,
TimelineNode,
TimelineContent,
TimelineHeader,
TimelineTitleArea,
TimelineTitle,
TimelineBadge,
TimelineActions,
TimelineTimestamp,
TimelineDescription,
TimelineEmpty,
} from '@/components/data-details';
import { Badge } from '@/components/ui/badge';
import { formatDate } from '@/lib/format';
import { trpc } from '@/lib/trpc';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { cn, getLanguageFromPath } from '@/lib/utils';
import { toast } from 'sonner';
import { GitCommitViewDialog } from './GitCommitViewDialog';
export interface FileDetailPanelProps {
/** 文件ID */
fileId: number;
/** 文件路径 */
path: string;
/** 文件名称 */
name: string;
/** 根容器样式类 */
className?: string;
}
// 定义 action 的标签和样式映射
const gitActionLabels = {
added: '新增',
modified: '修改',
renamed: '重命名',
deleted: '删除',
} as const;
const gitActionVariants = {
added: 'default' as const,
modified: 'secondary' as const,
renamed: 'outline' as const,
deleted: 'destructive' as const,
} as const;
/**
* 文件详情面板组件
* 显示文件的详细信息,包括从数据库获取的分析结果
*/
export function FileDetailPanel({
fileId,
path,
name,
className
}: FileDetailPanelProps) {
const [shouldLoadContent, setShouldLoadContent] = React.useState(false);
const [shouldLoadGitHistory, setShouldLoadGitHistory] = React.useState(false);
const [commitViewDialog, setCommitViewDialog] = React.useState<{
open: boolean;
commitId: string;
previousCommitId?: string;
}>({
open: false,
commitId: '',
});
// 查询文件的详细信息
const { data: fileDetail, isLoading, isError, error } = trpc.devFile!.getFileById.useQuery(
{ id: fileId },
{ enabled: !!path }
);
const { data: fileContent, isLoading: isContentLoading, isError: isContentError, error: contentError } = trpc.devFile!.getFileContent.useQuery(
{ id: fileId },
{ enabled: shouldLoadContent && !!fileId }
);
const { data: gitHistory, isLoading: isGitHistoryLoading, isError: isGitHistoryError, error: gitHistoryError } = trpc.devFile!.getFileGitHistory.useQuery(
{ id: fileId },
{ enabled: shouldLoadGitHistory && !!fileId }
);
if (error) {
toast.error("获取文件详情失败:" + error.toString().substring(0, 100))
}
if (isLoading) {
return (
<div className={cn("p-6 space-y-6 w-full", className)}>
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (isError || !fileDetail) {
return (
<div className={cn("p-6 space-y-4 w-full", className)}>
<div className="space-y-2">
<div className="flex items-center gap-3">
<FileCode className="size-6 text-muted-foreground" />
<h2 className="text-2xl font-bold">{name}</h2>
</div>
<p className="text-sm text-muted-foreground font-mono">
{path}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground"></h3>
<Badge variant="outline"></Badge>
</div>
{isError && (
<div className="mt-4 p-4 rounded-lg bg-muted/50">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
);
}
// 处理导出成员数据
const exportedMembers = (fileDetail.exportedMembers as Array<{ name: string; type: string }> | null) || []
// 按类型分组导出成员
const groupedMembers = exportedMembers.reduce((acc, member) => {
const type = member.type || '其他'
if (!acc[type]) {
acc[type] = []
}
acc[type].push({ label: member.name, variant: 'outline' as const })
return acc
}, {} as Record<string, Array<{ label: string; variant: 'outline' }>>)
// 处理依赖列表
const dependencyItems = (fileDetail.dependencies || []).map((dep, index) => ({
id: `dep-${index}`,
label: dep.targetFilePath,
description: dep.usageDescription || undefined,
icon: LinkIcon,
}))
// 处理包依赖列表
const pkgDependencyItems = (fileDetail.pkgDependencies || []).map((dep, index) => ({
id: `pkg-${index}`,
label: dep.packageName,
description: dep.usageDescription || undefined,
icon: Package,
}))
return (
<div className={cn("p-6 space-y-6 w-full", className)}>
{/* 基本信息 */}
<DetailSection title="基本信息" icon={FileCode}>
<DetailFieldGroup columns={2}>
<DetailField
label="文件路径"
value={<DetailCopyable value={fileDetail.path} truncate maxLength={100} />}
/>
<DetailField
label="文件名"
value={fileDetail.fileName}
copyable
/>
<DetailField
label="文件类型"
value={
<Badge variant="outline">
{fileDetail.fileType?.name || fileDetail.fileTypeId}
</Badge>
}
/>
<DetailField
label="Commit ID"
value={
fileDetail.commitId ? (
<div className="flex items-center gap-2">
<code className={cn("text-xs font-mono px-2 py-1 rounded", fileDetail.commitId.endsWith('*') ? "bg-default" : "bg-secondary")}>
{fileDetail.commitId}
</code>
{fileDetail.commitId.endsWith('*') && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)
}
/>
<DetailField
label="创建时间"
value={formatDate(fileDetail.createdAt, "PPP HH:mm:ss")}
/>
<DetailField
label="最后分析时间"
value={formatDate(fileDetail.lastAnalyzedAt, "PPP HH:mm:ss")}
/>
</DetailFieldGroup>
</DetailSection>
{/* 功能描述 */}
<DetailSection title="功能描述" icon={FileCode}>
<div className="space-y-4">
<DetailField
label="功能摘要"
value={fileDetail.summary}
/>
<DetailField
label="详细描述"
value={
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
{fileDetail.description}
</div>
}
/>
</div>
</DetailSection>
{/* 导出成员 */}
{exportedMembers.length > 0 && (
<DetailSection
title="导出成员"
description={`${exportedMembers.length} 个导出成员`}
icon={Code2}
collapsible
defaultOpen={true}
>
<DetailBadgeList
items={[]}
grouped
groups={groupedMembers}
/>
</DetailSection>
)}
{/* 依赖文件 */}
{fileDetail.dependencies && fileDetail.dependencies.length > 0 && (
<DetailSection
title="依赖文件"
description={`${fileDetail.dependencies.length} 个依赖`}
icon={LinkIcon}
collapsible
defaultOpen={false}
>
<DetailList
items={dependencyItems}
searchable
maxHeight="300px"
/>
</DetailSection>
)}
{/* 包依赖 */}
{fileDetail.pkgDependencies && fileDetail.pkgDependencies.length > 0 && (
<DetailSection
title="包依赖"
description={`${fileDetail.pkgDependencies.length} 个依赖包`}
icon={Package}
collapsible
defaultOpen={false}
>
<DetailList
items={pkgDependencyItems}
searchable
maxHeight="300px"
/>
</DetailSection>
)}
{/* 标签 */}
{fileDetail.tags && fileDetail.tags.length > 0 && (
<DetailSection
title="标签"
icon={Tag}
>
<DetailBadgeList
items={fileDetail.tags.map(tag => ({
label: tag,
variant: 'secondary' as const,
}))}
/>
</DetailSection>
)}
{/* Git变更历史 */}
<DetailSection
title="Git变更历史"
description="实时获取文件的所有Git提交记录"
icon={GitCommit}
collapsible
defaultOpen={false}
onOpenChange={(isOpen) => {
// 当展开时,启用历史加载
if (isOpen && !shouldLoadGitHistory) {
setShouldLoadGitHistory(true);
}
}}
>
{isGitHistoryLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
...
</div>
) : isGitHistoryError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
: {gitHistoryError?.message || '未知错误'}
</div>
) : !gitHistory || gitHistory.length === 0 ? (
<Timeline>
<TimelineEmpty>Git变更记录</TimelineEmpty>
</Timeline>
) : (
<Timeline className="break-all">
{gitHistory.map((item, index) => {
// 获取上一个commit ID用于对比
const previousCommitId = index < gitHistory.length - 1 ? gitHistory[index + 1]?.commitId : undefined;
return (
<TimelineItem key={item.commitId}>
<TimelineConnector />
<TimelineNode icon={GitCommit} />
<TimelineContent>
<TimelineHeader>
<TimelineTitleArea>
<TimelineTitle>Commit {item.commitId}</TimelineTitle>
<TimelineBadge variant={gitActionVariants[item.action]}>
{gitActionLabels[item.action]}
</TimelineBadge>
</TimelineTitleArea>
<TimelineActions>
<Button
variant="outline"
size="sm"
onClick={() => {
setCommitViewDialog({
open: true,
commitId: item.commitId,
previousCommitId,
});
}}
className="h-7 text-xs"
>
</Button>
</TimelineActions>
</TimelineHeader>
<TimelineTimestamp timestamp={item.timestamp} />
{item.oldPath && (
<TimelineDescription>
{item.oldPath}
</TimelineDescription>
)}
</TimelineContent>
</TimelineItem>
);
})}
</Timeline>
)}
</DetailSection>
{/* 文件内容 */}
<DetailSection
title="文件内容"
description="文件的完整源代码内容"
icon={Code2}
collapsible
defaultOpen={false}
onOpenChange={(isOpen) => {
// 当展开时,启用内容加载
if (isOpen && !shouldLoadContent) {
setShouldLoadContent(true);
}
}}
>
{isContentLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
...
</div>
) : isContentError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
: {contentError?.message || '未知错误'}
</div>
) : fileContent ? (
<DetailCodeBlock
code={fileContent}
language={getLanguageFromPath(path)}
title={name}
maxHeight="1000px"
/>
) : (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
</div>
)}
</DetailSection>
{/* Git Commit查看对话框 */}
<GitCommitViewDialog
open={commitViewDialog.open}
onOpenChange={(open) => setCommitViewDialog(prev => ({ ...prev, open }))}
fileId={fileId}
filePath={path}
commitId={commitViewDialog.commitId}
previousCommitId={commitViewDialog.previousCommitId}
/>
</div>
);
}

View File

@@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import { FileCode } from 'lucide-react'
import {
DetailSheet,
DetailHeader,
} from '@/components/data-details'
import { Badge } from '@/components/ui/badge'
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
import { SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
import { FileDetailPanel } from './FileDetailPanel'
export interface FileDetailSheetProps {
file: DevAnalyzedFile | null
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* 文件详情展示Sheet
* 使用通用详情展示框架展示DevAnalyzedFile对象的完整信息
*/
export function FileDetailSheet({
file,
open,
onOpenChange,
}: FileDetailSheetProps) {
if (!file) return null
return (
<DetailSheet
open={open}
onOpenChange={onOpenChange}
width="xl"
header={
<SheetTitle title={file.fileName}>
<DetailHeader
title={file.fileName}
subtitle={
<>
<Badge variant="outline" className="text-xs">
{file.fileType?.name || file.fileTypeId}
</Badge>
<span className="text-xs text-muted-foreground">
ID: {file.id}
</span>
</>
}
icon={<FileCode className="h-6 w-6" />}
/>
</SheetTitle>
}
description={<VisuallyHidden><SheetDescription>{ file.path }</SheetDescription></VisuallyHidden>}
>
<FileDetailPanel
fileId={file.id}
path={file.path}
name={file.fileName}
className='p-0 space-y-4'
/>
</DetailSheet>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { trpc } from '@/lib/trpc'
import { DetailCodeBlock } from '@/components/data-details'
import { getLanguageFromPath } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
export interface GitCommitViewDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
fileId: number
filePath: string
commitId: string
previousCommitId?: string
}
/**
* Git Commit查看对话框
* 显示指定commit的文件内容和与上个版本的差异
*/
export function GitCommitViewDialog({
open,
onOpenChange,
fileId,
filePath,
commitId,
previousCommitId,
}: GitCommitViewDialogProps) {
// 获取当前commit的文件内容
const { data: fileContent, isLoading: isContentLoading, isError: isContentError, error: contentError } =
trpc.devFile!.getFileContentAtCommit.useQuery(
{ id: fileId, commitId },
{ enabled: open }
)
// 获取与上个版本的差异
const { data: fileDiff, isLoading: isDiffLoading, isError: isDiffError, error: diffError } =
trpc.devFile!.getFileDiffBetweenCommits.useQuery(
{
id: fileId,
oldCommitId: previousCommitId || '',
newCommitId: commitId
},
{ enabled: open && !!previousCommitId }
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle> Commit {commitId}</DialogTitle>
<DialogDescription>{filePath}</DialogDescription>
</DialogHeader>
<Tabs defaultValue="content" className="flex-1 flex flex-col min-h-0">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="content"></TabsTrigger>
<TabsTrigger value="diff" disabled={!previousCommitId}>
</TabsTrigger>
</TabsList>
<TabsContent value="content" className="flex-1 overflow-auto mt-4">
{isContentLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : isContentError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
: {contentError?.message || '未知错误'}
</div>
) : fileContent ? (
<DetailCodeBlock
code={fileContent}
language={getLanguageFromPath(filePath)}
title={`${filePath} @ ${commitId}`}
maxHeight="600px"
/>
) : (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
</div>
)}
</TabsContent>
<TabsContent value="diff" className="flex-1 overflow-auto mt-4">
{!previousCommitId ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
</div>
) : isDiffLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : isDiffError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
: {diffError?.message || '未知错误'}
</div>
) : fileDiff ? (
<DetailCodeBlock
code={fileDiff}
language="diff"
title={`${previousCommitId}...${commitId}`}
maxHeight="600px"
/>
) : (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,180 @@
'use client'
import React from 'react'
import { trpc } from '@/lib/trpc'
import { Button } from '@/components/ui/button'
import { FolderSearch } from 'lucide-react'
import { toast } from 'sonner'
import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog'
import type { AnalyzeFoldersProgress } from '@/server/queues'
/**
* 扩展的分析进度类型
*/
interface AnalyzeProgress extends BaseTaskProgress, AnalyzeFoldersProgress {}
interface FolderAnalyzeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
jobId: string | null
onAnalyzeCompleted: () => void
}
interface FolderAnalyzeTriggerProps {
onStartAnalyze: () => void
isStarting: boolean
}
/**
* 文件夹分析触发器按钮
*/
export function FolderAnalyzeTrigger({
onStartAnalyze,
isStarting
}: FolderAnalyzeTriggerProps) {
return (
<Button
variant="default"
onClick={onStartAnalyze}
disabled={isStarting}
className="w-full"
>
<FolderSearch className="mr-2 h-4 w-4" />
{isStarting ? '启动中...' : '启动文件夹分析'}
</Button>
)
}
/**
* 文件夹分析进度对话框
*/
export function FolderAnalyzeDialog({
open,
onOpenChange,
jobId,
onAnalyzeCompleted
}: FolderAnalyzeDialogProps) {
// 停止分析任务 mutation
const cancelMutation = trpc.devFile!.cancelAnalyzeFoldersJob.useMutation({
onSuccess: () => {
toast.success('已发送停止请求')
},
onError: (error) => {
toast.error(error.message || '停止任务失败')
},
})
// 停止任务
const handleCancelTask = async (taskJobId: string) => {
await cancelMutation.mutateAsync({ jobId: taskJobId })
}
// 自定义状态消息渲染
const renderStatusMessage = (progress: AnalyzeProgress) => {
if (progress.state === 'waiting') {
return '任务等待中...'
} else if (progress.state === 'active') {
if (progress.currentFolder) {
return `正在分析: ${progress.currentFolder}`
}
return '正在分析文件夹...'
} else if (progress.state === 'completed') {
const successCount = (progress.analyzedFolders || 0) - (progress.failedFolders || 0)
const failedCount = progress.failedFolders || 0
const parts = [`成功 ${successCount}`]
if (failedCount > 0) {
parts.push(`失败 ${failedCount}`)
}
parts.push(`${progress.totalFolders || 0} 个文件夹`)
return `分析完成!${parts.join('')}`
} else if (progress.state === 'failed') {
return progress.error || '分析失败'
}
return ''
}
// 自定义详细信息渲染
const renderDetails = (progress: AnalyzeProgress) => {
if (progress.totalFolders === undefined && progress.analyzedFolders === undefined) {
return null
}
const successCount = (progress.analyzedFolders || 0) - (progress.failedFolders || 0)
return (
<div className="space-y-4">
{/* 进度统计 */}
<div className="grid grid-cols-2 gap-4 text-sm">
{progress.totalFolders !== undefined && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium">{progress.totalFolders}</span>
</div>
)}
{progress.analyzedFolders !== undefined && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium">{progress.analyzedFolders}</span>
</div>
)}
{successCount > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-green-600">{successCount}</span>
</div>
)}
{progress.failedFolders !== undefined && progress.failedFolders > 0 && (
<div>
<span className="text-muted-foreground"></span>
<span className="ml-1 font-medium text-red-600">{progress.failedFolders}</span>
</div>
)}
</div>
{/* 当前处理的文件夹 */}
{progress.currentFolder && progress.state === 'active' && (
<div className="rounded-md bg-muted p-3 text-sm">
<div className="text-muted-foreground mb-1"></div>
<div className="font-mono text-xs break-all">{progress.currentFolder}</div>
</div>
)}
{/* 最近的错误信息 */}
{progress.recentErrors && progress.recentErrors.length > 0 && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm">
<div className="text-red-800 font-medium mb-2"> (10)</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{progress.recentErrors.map((err, index) => (
<div key={index} className="text-xs">
<div className="font-mono text-red-700 break-all">{err.folderPath}</div>
<div className="text-red-600 mt-1">{err.error}</div>
{index < progress.recentErrors!.length - 1 && (
<div className="border-t border-red-200 mt-2" />
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
return (
<TaskDialog<AnalyzeProgress>
open={open}
onOpenChange={onOpenChange}
useSubscription={trpc.jobs.subscribeAnalyzeFoldersProgress.useSubscription}
jobId={jobId}
title="文件夹分析进度"
description="正在使用AI分析项目文件夹请稍候..."
onCancelTask={handleCancelTask}
onTaskCompleted={onAnalyzeCompleted}
isCancelling={cancelMutation.isPending}
renderStatusMessage={renderStatusMessage}
renderDetails={renderDetails}
/>
)
}

View File

@@ -0,0 +1,149 @@
'use client';
import React from 'react';
import { FolderIcon, Calendar, FileText } from 'lucide-react';
import {
DetailSection,
DetailField,
DetailFieldGroup,
} from '@/components/data-details';
import { Badge } from '@/components/ui/badge';
import { formatDate } from '@/lib/format';
import { trpc } from '@/lib/trpc';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
export interface FolderDetailPanelProps {
/** 文件夹路径 */
path: string;
/** 文件夹名称 */
name: string;
/** 子项数量 */
childrenCount?: number;
}
/**
* 文件夹详情面板组件
* 显示文件夹的详细信息,包括从数据库获取的分析结果
*/
export function FolderDetailPanel({
path,
name,
childrenCount = 0,
}: FolderDetailPanelProps) {
// 查询文件夹的详细信息
const { data: folderDetail, isLoading, isError, error } = trpc.devFile!.getFolderDetail.useQuery(
{ path },
{ enabled: !!path }
);
if (error) {
toast.error("获取文件夹详情失败:" + error.toString().substring(0, 100))
}
if (isLoading) {
return (
<div className="p-8 space-y-6 max-w-2xl w-full">
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-full" />
</div>
<Skeleton className="h-32 w-full" />
</div>
);
}
if (isError || !folderDetail) {
return (
<div className="p-8 space-y-4 max-w-2xl w-full">
<div className="space-y-2">
<div className="flex items-center gap-3">
<FolderIcon className="size-6 text-muted-foreground" />
<h2 className="text-2xl font-bold">{name}</h2>
</div>
<p className="text-sm text-muted-foreground font-mono">
{path}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground"></h3>
<Badge variant="outline"></Badge>
</div>
{childrenCount > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground">
</h3>
<p className="text-sm">{childrenCount} </p>
</div>
)}
{isError && (
<div className="mt-4 p-4 rounded-lg bg-muted/50">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
);
}
return (
<div className="p-8 space-y-6 max-w-2xl w-full">
{/* 标题区域 */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<FolderIcon className="size-6 text-primary" />
<h2 className="text-2xl font-bold">{name}</h2>
</div>
<p className="text-sm text-muted-foreground font-mono">
{path}
</p>
</div>
{/* 基本信息 */}
<DetailSection title="基本信息" icon={FolderIcon}>
<DetailFieldGroup columns={2}>
<DetailField
label="类型"
value={<Badge variant="outline"></Badge>}
/>
{childrenCount > 0 && (
<DetailField
label="子项数量"
value={`${childrenCount}`}
/>
)}
<DetailField
label="创建时间"
value={formatDate(folderDetail.createdAt, "PPP HH:mm:ss")}
/>
<DetailField
label="最后分析时间"
value={formatDate(folderDetail.lastAnalyzedAt, "PPP HH:mm:ss")}
/>
</DetailFieldGroup>
</DetailSection>
{/* 功能描述 */}
<DetailSection title="功能描述" icon={FileText}>
<div className="space-y-4">
<DetailField
label="功能摘要"
value={folderDetail.summary}
/>
<DetailField
label="详细描述"
value={
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
{folderDetail.description}
</div>
}
/>
</div>
</DetailSection>
</div>
);
}

View File

@@ -0,0 +1,312 @@
'use client';
import React, { useState, useMemo } from 'react';
import { Tree, TreeItem, TreeItemLabel } from '@/components/ui/tree';
import { hotkeysCoreFeature, syncDataLoaderFeature, searchFeature, expandAllFeature } from '@headless-tree/core';
import { useTree } from '@headless-tree/react';
import { FolderIcon, FolderOpenIcon, FileIcon } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { SourceFileIcon } from '@/components/icons/code-lang';
import { cn } from '@/lib/utils';
import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
import type { FileTreeItem } from '@/server/routers/dev/file';
import { ScrollArea } from '@/components/ui/scroll-area';
// 将树形结构扁平化为 Record 格式
function flattenTree(item: FileTreeItem, items: Record<string, FileTreeItem> = {}): Record<string, FileTreeItem> {
items[item.path] = item;
if (item.children) {
item.children.forEach(child => flattenTree(child, items));
}
return items;
}
// 获取文件扩展名
function getFileExtension(filename: string): string {
const lastDot = filename.lastIndexOf('.');
return lastDot > 0 ? filename.substring(lastDot + 1) : '';
}
const indent = 20;
export interface SearchDirectoryTreeProps {
/** 树形数据根节点 */
data: FileTreeItem;
/** 初始展开的节点路径列表 */
initialExpandedItems?: string[];
/** 是否显示摘要 */
showSummary?: boolean;
/** 列表项被选中时的回调(点击即选中) */
onItemSelect?: (item: FileTreeItem) => void;
/** 当前选中项的路径 */
selectedItemPath?: string | null;
/** 自定义类名 */
className?: string;
}
/**
* 可搜索的目录树组件
*
* 功能特性:
* - 支持搜索文件名、路径和摘要
* - 搜索时自动展开匹配项的父节点
* - 支持显示/隐藏摘要信息
* - 支持点击选中回调
* - 文件图标根据扩展名自动显示
*/
export function SearchDirectoryTree({
data,
initialExpandedItems = ['', 'src', 'src/app', 'src/components', 'src/server'],
showSummary: showSummaryProp = false,
onItemSelect,
selectedItemPath,
className,
}: SearchDirectoryTreeProps) {
const [showSummary, setShowSummary] = useState(showSummaryProp);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearchValue, setDebouncedSearchValue] = useState('');
const [expandedItems, setExpandedItems] = useState<string[]>(initialExpandedItems);
const [, setSavedExpandedItems] = useState<string[] | null>(null);
// 扁平化树形数据
const flatItems = useMemo(() => flattenTree(data), [data]);
// 搜索匹配逻辑(可复用)
const isItemMatching = React.useCallback((itemData: FileTreeItem, search: string) => {
const lowerSearch = search.toLowerCase();
return (
itemData.name.toLowerCase().includes(lowerSearch) ||
itemData.path.toLowerCase().includes(lowerSearch) ||
itemData.summary?.toLowerCase().includes(lowerSearch) || false
);
}, []);
// 在所有项中搜索(包括未展开的)
const searchAllItems = React.useCallback((search: string) => {
if (!search) return [];
const matchedPaths: string[] = [];
Object.values(flatItems).forEach(item => {
if (isItemMatching(item, search)) {
matchedPaths.push(item.path);
}
});
return matchedPaths;
}, [flatItems, isItemMatching]);
// 获取所有匹配项及其父节点路径
const getMatchedItemsAndParents = React.useCallback((search: string) => {
if (!search) return new Set<string>();
const matchedPaths = new Set<string>();
// 找到所有匹配的项
Object.values(flatItems).forEach(item => {
if (isItemMatching(item, search)) {
matchedPaths.add(item.path);
// 添加所有父节点路径
const parts = item.path.split('/').filter(Boolean);
for (let i = 1; i < parts.length; i++) {
const parentPath = parts.slice(0, i).join('/');
matchedPaths.add(parentPath);
}
// 添加根节点
if (parts.length > 0) {
matchedPaths.add('');
}
}
});
return matchedPaths;
}, [flatItems, isItemMatching]);
// 防抖更新搜索值
const debouncedSetSearchValue = useDebouncedCallback((value: string) => {
setDebouncedSearchValue(value);
}, 300);
// 初始化树
const tree = useTree<FileTreeItem>({
state: {
expandedItems,
},
setExpandedItems,
indent,
rootItemId: data.path,
getItemName: (item) => item.getItemData().name,
isItemFolder: (item) => item.getItemData().isFolder,
// 自定义搜索匹配逻辑
isSearchMatchingItem: (search: string, item) => {
return isItemMatching(item.getItemData(), search);
},
dataLoader: {
getItem: (itemId) => flatItems[itemId],
getChildren: (itemId) => {
const item = flatItems[itemId];
if (!item) return [];
return item.children?.map(child => child.path) ?? [];
},
},
features: [syncDataLoaderFeature, hotkeysCoreFeature, searchFeature, expandAllFeature],
});
// 当防抖后的搜索值变化时,处理展开状态
React.useEffect(() => {
if (debouncedSearchValue) {
// 开始搜索时,保存当前展开状态
setSavedExpandedItems(prev => {
if (prev === null) {
return expandedItems;
}
return prev;
});
// 在所有项中搜索(包括未展开的)
const matchedPaths = searchAllItems(debouncedSearchValue);
const itemsToExpand = new Set<string>();
// 收集所有匹配项的父节点路径
matchedPaths.forEach(path => {
const parts = path.split('/').filter(Boolean);
// 构建所有父路径
for (let i = 1; i < parts.length; i++) {
const parentPath = parts.slice(0, i).join('/');
itemsToExpand.add(parentPath);
}
});
setExpandedItems(Array.from(itemsToExpand));
} else {
// 搜索值为空时,恢复之前保存的展开状态
setSavedExpandedItems(prev => {
if (prev !== null) {
setExpandedItems(prev);
}
return null;
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchValue, searchAllItems]);
return (
<div className={cn('flex flex-col h-full', className)}>
{/* 搜索和选项区域 */}
<div className="p-4 border-b flex-shrink-0">
<div className="flex items-center gap-3">
<div className="flex-1 relative">
<input
{...tree.getSearchInputElementProps()}
value={searchValue}
onChange={(e) => {
const value = e.target.value;
setSearchValue(value);
debouncedSetSearchValue(value);
tree.getSearchInputElementProps().onChange?.(e);
}}
placeholder="搜索文件、路径或摘要..."
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
{tree.isSearchOpen() && debouncedSearchValue && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
{searchAllItems(debouncedSearchValue).length}
</span>
)}
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
<Checkbox
id="show-summary"
checked={showSummary}
onCheckedChange={(checked) => setShowSummary(checked === true)}
/>
<Label
htmlFor="show-summary"
className="text-sm font-normal cursor-pointer whitespace-nowrap"
>
</Label>
</div>
</div>
</div>
{/* 树形列表 */}
<ScrollArea>
<div className="flex-1 p-4">
<Tree
className="relative before:absolute before:inset-0 before:-ms-1 before:bg-[repeating-linear-gradient(to_right,transparent_0,transparent_calc(var(--tree-indent)-1px),var(--border)_calc(var(--tree-indent)-1px),var(--border)_calc(var(--tree-indent)))]"
indent={indent}
tree={tree}
>
{tree.getItems().map((item) => {
const itemData = item.getItemData();
const isMatched = item.isMatchingSearch();
const extension = !itemData.isFolder ? getFileExtension(itemData.name) : '';
// 搜索时隐藏不匹配的节点(但保留匹配节点的父节点)
const matchedItemsAndParents = getMatchedItemsAndParents(debouncedSearchValue);
const shouldHide = debouncedSearchValue && !matchedItemsAndParents.has(item.getId());
if (shouldHide) {
return null;
}
const isSelected = selectedItemPath === item.getId();
return (
<TreeItem key={item.getId()} item={item} data-search-match={undefined}>{/* data-search-match 默认样式比较丑这里自己实现了就不要了这个data slot了 */}
<TreeItemLabel
className={cn(
'before:bg-background relative before:absolute before:inset-x-0 before:-inset-y-0.5 before:-z-10',
isMatched && 'bg-blue-50 dark:bg-blue-950/30',
isSelected && 'bg-primary/10 dark:bg-primary/20 font-semibold'
)}
onClick={() => {
// 点击时触发选中回调
if (onItemSelect) {
onItemSelect(itemData);
}
}}
>
<div className="flex items-start gap-2 flex-1 min-w-0 group">
<div className="flex items-center gap-2 flex-shrink-0">
{itemData.isFolder ? (
item.isExpanded() ? (
<FolderOpenIcon className="text-muted-foreground size-4" />
) : (
<FolderIcon className="text-muted-foreground size-4" />
)
) : extension ? (
<SourceFileIcon
extension={extension}
className="size-4"
color="currentColor"
/>
) : (
<FileIcon className="text-muted-foreground size-4" />
)}
<span className="font-medium">{itemData.name}</span>
</div>
{itemData.summary && (
<span className={cn(
"text-xs text-muted-foreground truncate flex-1 min-w-0",
!showSummary && "hidden group-hover:inline"
)}>
{itemData.summary}
</span>
)}
</div>
</TreeItemLabel>
</TreeItem>
);
})}
</Tree>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import React, { useState } from 'react';
import { FileCode, FileIcon, Loader2 } from 'lucide-react';
import type { FileTreeItem } from '@/server/routers/dev/file';
import { SearchDirectoryTree } from './components/SearchDirectoryTree';
import { CarouselLayout, CarouselColumn } from '@/components/layout/carousel-layout';
import { FolderAnalyzeTrigger, FolderAnalyzeDialog } from './components/FolderAnalyzeDialog';
import { FolderDetailPanel } from './components/FolderDetailPanel';
import { FileDetailPanel } from '../components/FileDetailPanel';
import { trpc } from '@/lib/trpc';
import { toast } from 'sonner';
export default function DirectoryTreePage() {
const [selectedItem, setSelectedItem] = useState<FileTreeItem | null>(null);
const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false);
const [analyzeJobId, setAnalyzeJobId] = useState<string | null>(null);
// 获取目录树数据
const { data: fileTree, isLoading, error, refetch } = trpc.devFile!.getDirectoryTree.useQuery();
if (error) {
toast.error("获取目录树失败:" + error.toString().substring(0, 100))
}
// 启动文件夹分析任务
const startAnalyzeMutation = trpc.devFile!.startAnalyzeFolders.useMutation({
onSuccess: (data) => {
setAnalyzeJobId(data.jobId as string);
setIsAnalyzeDialogOpen(true);
},
onError: (error) => {
toast.error(error.message || '启动文件夹分析失败');
},
});
// 处理列表项选中
const handleItemSelect = (item: FileTreeItem) => {
setSelectedItem(item);
};
// 启动分析
const handleStartAnalyze = () => {
startAnalyzeMutation.mutate();
};
// 分析完成回调
const handleAnalyzeCompleted = () => {
console.log('文件夹分析完成');
// 重新获取目录树数据
refetch();
};
// 详情内容组件
const DetailContent = () => {
if (!selectedItem) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<FileIcon className="size-16 text-muted-foreground mx-auto" />
<p className="text-muted-foreground text-sm">
</p>
{/* 文件夹分析按钮 */}
<div className="pt-4 border-t">
<FolderAnalyzeTrigger
onStartAnalyze={handleStartAnalyze}
isStarting={startAnalyzeMutation.isPending}
/>
<p className="text-xs text-muted-foreground mt-2 text-center">
使 AI
</p>
</div>
</div>
</div>
);
}
// 根据选中项类型显示对应的详情组件
if (selectedItem.isFolder) {
return (
<div className="flex items-center justify-center min-h-full py-8 shadow-inner">
<FolderDetailPanel
path={selectedItem.path}
name={selectedItem.name}
childrenCount={selectedItem.children?.length || 0}
/>
</div>
);
} else {
return (<div className='shadow-inner'>
<div className="space-y-2 p-6">
<div className="flex items-center gap-3">
<FileCode className="size-6 text-primary" />
<h2 className="text-2xl font-bold">{selectedItem.name}</h2>
</div>
<p className="text-sm text-muted-foreground font-mono break-all">
{selectedItem.path}
</p>
</div>
<FileDetailPanel
fileId={selectedItem.fileId!}
path={selectedItem.path}
name={selectedItem.name}
/>
</div>
);
}
};
// 配置列
const columns: CarouselColumn[] = [
{
id: 'tree',
title: '目录树',
content: isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full space-y-4 p-4">
<p className="text-destructive text-sm">: {error.message}</p>
<button
onClick={() => refetch()}
className="text-sm text-primary hover:underline"
>
</button>
</div>
) : fileTree ? (
<SearchDirectoryTree
data={fileTree}
onItemSelect={handleItemSelect}
selectedItemPath={selectedItem?.path ?? null}
/>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground text-sm"></p>
</div>
),
desktopClassName: 'w-1/3 border-r',
mobileClassName: '',
},
{
id: 'detail',
title: '详情',
content: <DetailContent />,
desktopClassName: 'w-2/3 bg-muted/20',
mobileClassName: 'bg-muted/20',
},
];
return (
<>
<CarouselLayout
columns={columns}
defaultActiveIndex={0}
className="h-[calc(100vh-14rem)]"
/>
{/* 文件夹分析进度对话框 */}
<FolderAnalyzeDialog
open={isAnalyzeDialogOpen}
onOpenChange={setIsAnalyzeDialogOpen}
jobId={analyzeJobId}
onAnalyzeCompleted={handleAnalyzeCompleted}
/>
</>
);
}

View File

@@ -0,0 +1,61 @@
'use client'
import React, { useCallback, useState } from 'react'
import { trpc } from '@/lib/trpc'
import { FileDetailSheet } from '../components/FileDetailSheet'
import { FileDependencyGraph } from '../components/FileDependencyGraph'
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
import { toast } from 'sonner'
import { Skeleton } from '@/components/ui/skeleton'
export default function FileGraphPage() {
// 用于刷新数据的 utils
const utils = trpc.useUtils()
// 详情Sheet状态
const [selectedFile, setSelectedFile] = useState<DevAnalyzedFile | null>(null)
const [detailSheetOpen, setDetailSheetOpen] = useState(false)
// 获取依赖图数据
const { data: graphData, isLoading: isGraphLoading } = trpc.devFile!.getDependencyGraph.useQuery()
// 处理依赖图节点点击
const handleGraphNodeClick = useCallback(async (node: { id: string; path: string }) => {
try {
const fileId = parseInt(node.id)
// 通过 tRPC 查询文件详情
const fileDetail = await utils.devFile!.getFileById.fetch({ id: fileId })
setSelectedFile(fileDetail as DevAnalyzedFile)
setDetailSheetOpen(true)
} catch (error) {
toast.error(`获取文件详情失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}, [utils])
return (
<>
{isGraphLoading ? (
<div className="space-y-4">
<Skeleton className="h-[800px] max-h-[calc(100vh-12rem)] w-full" />
</div>
) : graphData ? (
<FileDependencyGraph
nodes={graphData.nodes}
edges={graphData.edges}
onNodeClick={handleGraphNodeClick}
/>
) : (
<div className="flex items-center justify-center h-[800px] max-h-[calc(100vh-12rem)] border rounded-lg bg-muted/50">
<p className="text-muted-foreground"></p>
</div>
)}
{/* 文件详情Sheet */}
<FileDetailSheet
file={selectedFile}
open={detailSheetOpen}
onOpenChange={setDetailSheetOpen}
/>
</>
)
}

View File

@@ -0,0 +1,9 @@
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
export default function FilePageLayout({
children,
}: {
children: React.ReactNode;
}) {
return <SubMenuLayout parentHref="/dev/file">{children}</SubMenuLayout>;
}

View File

@@ -0,0 +1,289 @@
'use client'
import React, { useCallback, useMemo, useState, Suspense } from 'react'
import { trpc } from '@/lib/trpc'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { FileAnalyzeDialog } from '../components/FileAnalyzeDialog'
import { FileDetailSheet } from '../components/FileDetailSheet'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/toolbar'
import { createDevAnalyzedFileColumns, type DevAnalyzedFileColumnsOptions } from '../columns'
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
import { useDataTable } from '@/hooks/use-data-table'
import { keepPreviousData } from '@tanstack/react-query'
import { DataTableSortList } from '@/components/data-table/sort-list'
import { toast } from 'sonner'
import { DataTableSkeleton } from '@/components/data-table/table-skeleton'
import { Skeleton } from '@/components/ui/skeleton'
import { FileText, GitCommit, Package, Clock } from 'lucide-react'
import { format } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { StatsCardGroup, StatsCardWrapper, type StatsCardItem } from '@/components/common/stats-card-group'
// 计算相对时间描述
function getRelativeTimeLabel(date: Date | string | null | undefined): string {
if (!date) return '-'
const now = new Date()
const targetDate = new Date(date)
const diffMs = now.getTime() - targetDate.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMs <= 1000 * 60 * 5) return '刚刚'
if (diffMs <= 1000 * 60 * 60) return '一小时内'
if (diffDays === 0) return '一天内'
if (diffDays === 1) return '两天内'
if (diffDays === 2) return '三天前'
if (diffDays > 7) return '超过一星期前'
if (diffDays > 30) return '超过一个月前'
return '未来...?'
}
// 统计概览组件
function StatsOverview() {
const { data: latestAnalyzedTime, isLoading: isLoadingLatestTime } = trpc.devFile!.getLatestAnalyzedTime.useQuery()
const { data: fileTypeStats, isLoading: isLoadingFileTypes } = trpc.devFile!.getFileTypeStats.useQuery()
const { data: commitIdStats, isLoading: isLoadingCommits } = trpc.devFile!.getCommitIdStats.useQuery()
const { data: pkgDependencyStats, isLoading: isLoadingPkgs } = trpc.devFile!.getPkgDependencyStats.useQuery()
const totalFiles = useMemo(() => {
if (!fileTypeStats) return 0
return fileTypeStats.reduce((sum, item) => sum + item.count, 0)
}, [fileTypeStats])
const latestCommit = useMemo(() => {
if (!commitIdStats || commitIdStats.length === 0) return null
return commitIdStats[0]
}, [commitIdStats])
const totalDependencies = useMemo(() => {
if (!pkgDependencyStats) return 0
return pkgDependencyStats.length
}, [pkgDependencyStats])
// 计算相对时间标题
const relativeTimeTitle = useMemo(() =>
getRelativeTimeLabel(latestAnalyzedTime),
[latestAnalyzedTime]
)
// 构建统计卡片数据
const statsCards: StatsCardItem[] = useMemo(() => [
{
id: 'latest-analyzed-time',
title: '最近分析时间',
icon: Clock,
content: (
<StatsCardWrapper>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoadingLatestTime ? (
<Skeleton className="h-8 w-24" />
) : (
<>
<div className="text-2xl font-bold">
{latestAnalyzedTime ? relativeTimeTitle : '-'}
</div>
<p className="text-xs text-muted-foreground mt-1">
{latestAnalyzedTime
? format(new Date(latestAnalyzedTime), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN })
: '暂无数据'}
</p>
</>
)}
</CardContent>
</StatsCardWrapper>
),
},
{
id: 'latest-commit',
title: '最新提交',
icon: GitCommit,
content: (
<StatsCardWrapper>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<GitCommit className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoadingCommits ? (
<Skeleton className="h-8 w-24" />
) : (
<>
<div className="text-2xl font-bold">{latestCommit?.name.substring(0, 7) || '-'}</div>
<p className="text-xs text-muted-foreground mt-1">
{latestCommit?.minAnalyzedAt
? format(new Date(latestCommit.minAnalyzedAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })
: '暂无数据'}
</p>
</>
)}
</CardContent>
</StatsCardWrapper>
),
},
{
id: 'total-files',
title: '文件总数',
icon: FileText,
content: (
<StatsCardWrapper>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoadingFileTypes ? (
<Skeleton className="h-8 w-24" />
) : (
<>
<div className="text-2xl font-bold">{totalFiles}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</>
)}
</CardContent>
</StatsCardWrapper>
),
},
{
id: 'total-dependencies',
title: '依赖包数',
icon: Package,
content: (
<StatsCardWrapper>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoadingPkgs ? (
<Skeleton className="h-8 w-24" />
) : (
<>
<div className="text-2xl font-bold">{totalDependencies}</div>
<p className="text-xs text-muted-foreground mt-1">使</p>
</>
)}
</CardContent>
</StatsCardWrapper>
),
},
], [relativeTimeTitle, latestAnalyzedTime, latestCommit, totalFiles, totalDependencies, isLoadingLatestTime, isLoadingFileTypes, isLoadingCommits, isLoadingPkgs])
return <StatsCardGroup items={statsCards} gridClassName="md:grid-cols-2 lg:grid-cols-4" />
}
interface DevFilePageDataTableProps {
children?: React.ReactNode
}
function DevFilePageDataTable({ children }: DevFilePageDataTableProps) {
// 详情Sheet状态
const [selectedFile, setSelectedFile] = useState<DevAnalyzedFile | null>(null)
const [detailSheetOpen, setDetailSheetOpen] = useState(false)
const { data: fileTypeStats } = trpc.devFile!.getFileTypeStats.useQuery()
const { data: commitIdStats } = trpc.devFile!.getCommitIdStats.useQuery()
const { data: tagsStats } = trpc.devFile!.getTagsStats.useQuery()
const { data: pkgDependencyStats } = trpc.devFile!.getPkgDependencyStats.useQuery()
// 处理查看详情
const handleViewDetail = useCallback((file: DevAnalyzedFile) => {
setSelectedFile(file)
setDetailSheetOpen(true)
}, [])
// 创建表格列定义选项
const columnsOptions: DevAnalyzedFileColumnsOptions = useMemo(() => ({
fileTypes: fileTypeStats || [],
commitIds: commitIdStats || [],
tagsStats: tagsStats || [],
pkgDependencyStats: pkgDependencyStats || [],
onViewDetail: handleViewDetail,
}), [fileTypeStats, commitIdStats, tagsStats, pkgDependencyStats, handleViewDetail])
// 创建表格列定义
const columns = useMemo(() => createDevAnalyzedFileColumns(columnsOptions), [columnsOptions])
// 使用 useDataTable hook
const { table, queryResult } = useDataTable<DevAnalyzedFile>({
columns,
initialState: {
pagination: { pageIndex: 1, pageSize: 10 },
columnPinning: { left: ['select'], right: ['actions'] },
sorting: [ { id: 'lastAnalyzedAt', desc: true } ] ,
columnVisibility: {
path: false,
description: false,
exportedMembers: false,
dependencies: false,
pkgDependencies: true,
}
},
getRowId: (row) => String(row.id),
queryFn: useCallback((params) => {
const result = trpc.devFile!.listAnalyzedFiles.useQuery(params, {
placeholderData: keepPreviousData,
})
if (result.error) {
toast.error('获取文件数据失败:' + result.error.toString().substring(0, 100))
}
return result
}, []),
})
return (
<>
<DataTable table={table} tableClassName={queryResult?.data?.total ? "table-fixed" : "table-auto"} isLoading={queryResult.isLoading}>
<DataTableToolbar table={table}>
{children}
<DataTableSortList table={table} />
</DataTableToolbar>
</DataTable>
{/* 文件详情Sheet */}
<FileDetailSheet
file={selectedFile}
open={detailSheetOpen}
onOpenChange={setDetailSheetOpen}
/>
</>
)
}
export default function FileListPage() {
// 用于刷新数据的 utils
const utils = trpc.useUtils()
// 刷新文件列表
const handleRefreshFiles = useCallback(() => {
utils.devFile!.listAnalyzedFiles.invalidate()
utils.devFile!.getLatestAnalyzedTime.invalidate()
utils.devFile!.getFileTypeStats.invalidate()
utils.devFile!.getCommitIdStats.invalidate()
utils.devFile!.getTagsStats.invalidate()
utils.devFile!.getPkgDependencyStats.invalidate()
}, [utils])
return (
<div className="space-y-2 md:space-y-6">
{/* 统计概览区域 */}
<StatsOverview />
{/* 文件列表表格 */}
<Card className='py-2 xl:py-4 2xl:py-6'>
<CardContent>
<Suspense fallback={<DataTableSkeleton columnCount={8} rowCount={10} />}>
<DevFilePageDataTable>
<FileAnalyzeDialog onAnalyzeCompleted={handleRefreshFiles} />
</DevFilePageDataTable>
</Suspense>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
export default function FilePage() {
return <SubMenuRedirect parentHref="/dev/file" />;
}

View File

@@ -0,0 +1,9 @@
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
export default function FrontendDesignLayout({
children,
}: {
children: React.ReactNode;
}) {
return <SubMenuLayout parentHref="/dev/frontend-design">{children}</SubMenuLayout>;
}

View File

@@ -0,0 +1,5 @@
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
export default function FrontendDesignPage() {
return <SubMenuRedirect parentHref="/dev/frontend-design" />;
}

View File

@@ -0,0 +1,19 @@
"use client";
export default function PageTestPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-4"></h2>
<p className="text-muted-foreground">
</p>
</div>
{/* 这里可以添加完整页面的测试和展示 */}
<div className="space-y-4">
{/* 示例区域 */}
</div>
</div>
);
}

View File

@@ -0,0 +1,372 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Search, Loader2, Package, Sparkles, Eye, ExternalLink } from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { CardSelect } from "@/components/common/card-select";
import { ComponentDetailDialog } from "./ComponentDetailDialog";
interface AddComponentSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* 添加组件的Sheet组件
* 包含registry列表、搜索栏和搜索结果展示
*/
export function AddComponentSheet({ open, onOpenChange }: AddComponentSheetProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedRegistries, setSelectedRegistries] = useState<string[]>([]);
const [searchResults, setSearchResults] = useState<Array<{
registry: string;
items: Array<{
name: string;
description?: string;
type?: string;
addCommandArgument?: string;
}>;
error?: string;
}>>([]);
// 组件详情对话框状态
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [selectedComponentName, setSelectedComponentName] = useState("");
// 搜索框ref用于自动聚焦
const searchInputRef = useRef<HTMLInputElement>(null);
// 获取registry列表
const { data: registriesData, isLoading: isLoadingRegistries } = trpc.devFrontendDesign!.getRegistries.useQuery();
// 在registry列表中添加shadcn官方仓库并放在第一项
const registries = registriesData ? [
{
name: '@shadcn',
url: 'https://registry.shadcn.com/{name}.json',
websiteUrl: 'https://ui.shadcn.com',
},
...registriesData.filter(r => r.name !== '@shadcn'), // 避免重复
] : undefined;
// 当registry列表加载完成后默认选中@shadcn
const [hasInitialized, setHasInitialized] = useState(false);
if (registries && !hasInitialized && selectedRegistries.length === 0) {
setSelectedRegistries(['@shadcn']);
setHasInitialized(true);
}
// 当Sheet打开时自动聚焦到搜索框
useEffect(() => {
if (open) {
// 使用setTimeout确保Sheet动画完成后再聚焦
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [open]);
// 搜索组件
const searchMutation = trpc.devFrontendDesign!.searchComponents.useMutation({
onSuccess: (data) => {
setSearchResults(data);
const totalItems = data.reduce((sum, r) => sum + r.items.length, 0);
const errorCount = data.filter(r => r.error).length;
if (totalItems === 0 && errorCount === 0) {
toast.info("未找到匹配的组件");
} else if (errorCount > 0) {
toast.warning(`搜索完成,但有 ${errorCount} 个registry查询失败`);
} else {
toast.success(`找到 ${totalItems} 个组件`);
}
},
onError: (error) => {
toast.error(`搜索失败: ${error.message}`);
},
});
const handleSearch = () => {
if (!searchQuery.trim()) {
toast.error("请输入搜索关键词");
return;
}
if (selectedRegistries.length === 0) {
toast.error("请至少选择一个registry");
return;
}
searchMutation.mutate({
registries: selectedRegistries,
query: searchQuery.trim(),
});
};
const handleRegistryChange = (value: (string | number)[]) => {
setSelectedRegistries(value as string[]);
};
const selectAllRegistries = () => {
if (registries) {
setSelectedRegistries(registries.map(r => r.name));
}
};
const clearSelection = () => {
setSelectedRegistries([]);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-5 w-5 text-primary" />
</div>
<div>
<SheetTitle className="text-xl"></SheetTitle>
<SheetDescription className="text-xs">
registry搜索并添加新的UI组件到项目中
</SheetDescription>
</div>
</div>
</SheetHeader>
<Separator />
<div className="px-4 space-y-3">
{/* Registry列表 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Registry
</Label>
<p className="text-xs text-muted-foreground">
{selectedRegistries.length} / {registries?.length || 0}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllRegistries}
disabled={isLoadingRegistries}
className="h-8"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={clearSelection}
disabled={selectedRegistries.length === 0}
className="h-8"
>
</Button>
</div>
</div>
{isLoadingRegistries ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : registries && registries.length > 0 ? (
<CardSelect
value={selectedRegistries}
onChange={handleRegistryChange}
options={registries.map(r => ({
id: r.name,
name: r.name,
description: r.url,
websiteUrl: r.websiteUrl
}))}
showCheckbox={true}
showExternalLink={true}
disabled={isLoadingRegistries}
enablePagination={true}
pageSize={3}
showPaginationInfo={true}
className="min-h-61"
/>
) : (
<div className="text-center py-8 px-4 border rounded-lg bg-muted/30">
<Package className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
registry
</p>
</div>
)}
</div>
<Separator />
{/* 搜索框 */}
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Search className="h-4 w-4 text-primary" />
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="输入组件名称或关键词,如 full screen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !searchMutation.isPending) {
handleSearch();
}
}}
className="pl-9"
disabled={searchMutation.isPending}
/>
</div>
<Button
onClick={handleSearch}
disabled={searchMutation.isPending}
className="px-6"
>
{searchMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
</>
) : (
"搜索"
)}
</Button>
</div>
</div>
{/* 搜索结果 */}
{searchResults.length > 0 && (
<div className="space-y-4">
<Separator />
<div className="flex items-center justify-between">
<Label className="text-base font-semibold flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
</Label>
<Badge variant="secondary" className="text-xs">
{searchResults.reduce((sum, r) => sum + r.items.length, 0)}
</Badge>
</div>
<div className="space-y-4">
{searchResults.map((result) => {
// 查找对应registry的websiteUrl
const registryInfo = registries?.find(r => r.name === result.registry);
const websiteUrl = registryInfo?.websiteUrl;
return (
<div key={result.registry} className="space-y-3">
<div className="flex items-center gap-2 px-2">
<Badge variant="outline" className="font-mono">
{result.registry}
</Badge>
{result.error ? (
<span className="text-xs text-destructive flex items-center gap-1">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-destructive" />
{result.error}
</span>
) : (
<span className="text-xs text-muted-foreground">
{result.items.length}
</span>
)}
{websiteUrl && (
<a
href={websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-muted-foreground hover:text-primary transition-colors"
title={`访问 ${result.registry} 官网`}
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
{result.items.length > 0 && (
<CardSelect
value={[]}
onChange={() => {}}
options={result.items.map((item, index) => ({
id: `${result.registry}-${item.name}-${index}`,
name: item.name,
description: item.description,
type: item.type,
addCommandArgument: item.addCommandArgument
}))}
showCheckbox={false}
showBadge={true}
containerClassName="space-y-2 pl-4 border-l-2 border-primary/20"
className="group p-4 rounded-lg border bg-card hover:shadow-md hover:border-primary/50 transition-all"
renderExtra={(option) => (
option.addCommandArgument ? (
<div className="flex items-center gap-2 mt-2">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono border">
{option.addCommandArgument}
</code>
</div>
) : null
)}
renderActions={(option) => (
<Button
size="sm"
variant="outline"
className="opacity-70 group-hover:opacity-100 transition-opacity"
onClick={() => {
if (option.addCommandArgument) {
setSelectedComponentName(option.addCommandArgument);
setDetailDialogOpen(true);
}
}}
>
<Eye className="h-4 w-4 mr-1.5" />
</Button>
)}
/>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</SheetContent>
{/* 组件详情对话框 */}
<ComponentDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
componentName={selectedComponentName}
/>
</Sheet>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { Loader2, Package, FileCode, Terminal } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { DetailCodeBlock } from "@/components/data-details/detail-code-block";
import { DetailCopyable } from "@/components/data-details/detail-copyable";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
interface ComponentDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
componentName: string; // 如 @shadcn/tabs
}
/**
* 组件详情对话框
* 展示组件的详细信息,包括依赖、文件内容等
*/
export function ComponentDetailDialog({
open,
onOpenChange,
componentName,
}: ComponentDetailDialogProps) {
// 获取组件详情
const viewComponentMutation = trpc.devFrontendDesign!.viewComponent.useMutation({
onError: (error) => {
toast.error(`获取组件详情失败: ${error.message}`);
},
});
// 当对话框打开时,触发查询
const [hasQueried, setHasQueried] = useState(false);
if (open && !hasQueried && !viewComponentMutation.isPending) {
viewComponentMutation.mutate({ componentName });
setHasQueried(true);
}
// 当对话框关闭时,重置状态
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setHasQueried(false);
}
onOpenChange(newOpen);
};
// 解析组件详情数据
const componentInfo = viewComponentMutation.data?.[0];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle className="text-xl flex items-center gap-2">
{componentName}
{componentInfo?.type && (
<Badge variant="secondary" className="text-xs">
{componentInfo.type}
</Badge>
)}
</DialogTitle>
<DialogDescription>
</DialogDescription>
</div>
</div>
</DialogHeader>
{viewComponentMutation.isPending ? (
<div className="space-y-4 py-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : viewComponentMutation.error ? (
<div className="py-8 text-center">
<p className="text-sm text-destructive">{viewComponentMutation.error.message}</p>
</div>
) : componentInfo ? (
<div className="space-y-4">
{/* 添加组件命令 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Terminal className="h-4 w-4" />
</h3>
<DetailCopyable
value={`npx shadcn@latest add ${componentName}`}
className="bg-muted/30 p-3 rounded-lg border"
/>
</div>
<Separator />
{/* 依赖信息 */}
{componentInfo.dependencies && componentInfo.dependencies.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Package className="h-4 w-4" />
</h3>
<div className="flex flex-wrap gap-2">
{componentInfo.dependencies.map((dep: string) => (
<Badge key={dep} variant="outline" className="font-mono text-xs">
{dep}
</Badge>
))}
</div>
</div>
)}
{/* 文件内容 */}
{componentInfo.files && componentInfo.files.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<FileCode className="h-4 w-4" />
</h3>
{componentInfo.files.length === 1 ? (
<DetailCodeBlock
code={componentInfo.files[0].content}
language="tsx"
title={componentInfo.files[0].path}
maxHeight="500px"
/>
) : (
<Tabs defaultValue="0" className="w-full">
<TabsList className="w-full justify-start overflow-x-auto flex-wrap">
{componentInfo.files.map((file: any, index: number) => (
<TabsTrigger key={index} value={String(index)} className="whitespace-nowrap">
{file.path.split('/').pop()}
</TabsTrigger>
))}
</TabsList>
{componentInfo.files.map((file: any, index: number) => (
<TabsContent key={index} value={String(index)}>
<DetailCodeBlock
code={file.content}
language="tsx"
title={file.path}
maxHeight="500px"
/>
</TabsContent>
))}
</Tabs>
)}
</div>
)}
</div>
) : (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground"></p>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,411 @@
"use client";
import { useState, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loader2, Plus, Package, Sparkles, Code2, Eye, Search, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import React from 'react';
import { useForm } from "react-hook-form"
import { useDataTable } from "@/hooks/use-data-table";
import { cn } from "@/lib/utils";
import { blobUrlToBase64 } from "@/lib/format";
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
PromptInputSubmit,
PromptInputAttachments,
PromptInputAttachment,
PromptInputActionMenu,
PromptInputActionMenuTrigger,
PromptInputActionMenuContent,
PromptInputActionAddAttachments,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { AddComponentSheet } from "./components/AddComponentSheet";
import { CardSelect } from "@/components/common/card-select";
import { CodeEditorPreview } from "@/components/features/code-editor-preview";
/**
* 动态导入组件模块
*/
async function importComponentModule(path: string) {
// 清理路径:移除 src/components/ 前缀和 .tsx/.ts 后缀
const cleanPath = path
.replace(/^src\/components\//, '')
.replace(/\.(tsx|ts)$/, '');
try {
const importedModule = await import(`@/components/${cleanPath}`);
return importedModule;
} catch (error) {
console.error(`Failed to import component from ${path}:`, error);
return null;
}
}
export default function UIComponentsPage() {
const [selectedComponents, setSelectedComponents] = useState<string[]>([]);
const [generatedCode, setGeneratedCode] = useState<string | null>(null);
const [componentScope, setComponentScope] = useState<Record<string, any>>({});
const [searchQuery, setSearchQuery] = useState("");
const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready");
const [addSheetOpen, setAddSheetOpen] = useState(false);
// 获取UI组件列表
const { data: components, isLoading: isLoadingComponents } = trpc.devFrontendDesign!.getUIComponents.useQuery();
// 筛选和排序组件:选中的组件排在前面
const filteredComponents = useMemo(() => {
if (!components) return [];
let filtered = components;
// 如果有搜索关键词,先筛选并计算匹配优先级
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
// 为每个组件计算匹配优先级1=fileName, 2=path, 3=summary, 0=不匹配
const componentsWithPriority = components.map(c => {
const fileNameMatch = c.fileName.toLowerCase().includes(query);
const pathMatch = c.path.toLowerCase().includes(query);
const summaryMatch = c.summary.toLowerCase().includes(query);
let priority = 0;
if (fileNameMatch) priority = 1;
else if (pathMatch) priority = 2;
else if (summaryMatch) priority = 3;
return { component: c, priority };
});
// 过滤掉不匹配的,并按优先级排序
filtered = componentsWithPriority
.filter(item => item.priority > 0)
.sort((a, b) => a.priority - b.priority)
.map(item => item.component);
}
// 将选中的组件排到前面
return filtered.sort((a, b) => {
const aSelected = selectedComponents.includes(a.path);
const bSelected = selectedComponents.includes(b.path);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
return 0;
});
}, [components, searchQuery, selectedComponents]);
// 生成演示代码
const generateMutation = trpc.devFrontendDesign!.generateComponentDemo.useMutation({
onSuccess: async (data) => {
// 动态构建scope
const scope: Record<string, any> = {
React,
useState: React.useState,
useEffect: React.useEffect,
useMemo: React.useMemo,
useCallback: React.useCallback,
useRef: React.useRef,
useForm: useForm,
useDataTable: useDataTable,
cn,
};
// 导入所有选中的组件
for (const component of data.components) {
const importedModule = await importComponentModule(component.path);
if (importedModule) {
// 将所有导出的成员添加到scope
for (const member of component.exportedMembers) {
if (importedModule[member.name]) {
scope[member.name] = importedModule[member.name];
}
}
}
}
setComponentScope(scope);
setGeneratedCode(data.code);
setStatus("ready");
toast.success("代码生成成功!");
},
onError: (error) => {
setStatus("error");
toast.error(`生成失败: ${error.message}`);
},
});
const handleSubmit = async (message: PromptInputMessage) => {
if (status === "streaming") { // 还在生成结果这时候用户再点提交就reset
generateMutation.reset();
setStatus("ready");
return;
}
if (!message.text) {
toast.error("请输入提示词");
return;
}
setStatus("submitted");
// 将图片转换为base64
const images: Array<{ url: string; mediaType: string }> = [];
if (message.files?.length) {
for (const file of message.files) {
if (file.url && file.mediaType?.startsWith('image/')) {
try {
const base64 = await blobUrlToBase64(file.url);
images.push({ url: base64, mediaType: file.mediaType });
} catch (error) {
console.error('转换图片失败:', error);
toast.error('图片处理失败');
}
}
}
}
setTimeout(() => {
setStatus("streaming");
generateMutation.mutate({
componentPaths: selectedComponents,
prompt: message.text!.trim(),
images: images.length > 0 ? images : undefined,
});
}, 100);
};
const handleComponentChange = (value: (string | number)[]) => {
setSelectedComponents(value as string[]);
};
return (
<div className="h-[calc(100vh-14rem)] flex flex-col">
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-4 lg:min-h-0 min-h-200">
{/* 左侧:组件选择 */}
<Card className="lg:col-span-1 flex flex-col min-h-0 shadow-lg border-2 border-border/50 min-h-160">
<CardHeader className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<CardTitle className="text-xl"></CardTitle>
<CardDescription className="text-xs">
使UI组件
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setAddSheetOpen(true)}
className="h-8 gap-1.5"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">
{selectedComponents.length}
</Badge>
{filteredComponents.length < (components?.length || 0) && (
<span className="text-xs text-muted-foreground">
{filteredComponents.length}/{components?.length || 0}
</span>
)}
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 space-y-4">
{/* 搜索框 */}
<div className="space-y-2 flex-shrink-0">
<Label className="text-sm font-medium flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-primary" />
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="输入组件名称、路径或描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 组件列表区域 - 可滚动 */}
<div className="flex-1 flex flex-col min-h-0 space-y-3">
{isLoadingComponents ? (
<div className="flex items-center justify-center py-12">
<div className="text-center space-y-3">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
) : filteredComponents.length > 0 ? (
<>
<Label className="text-sm font-medium flex items-center gap-2 flex-shrink-0">
<Package className="h-3.5 w-3.5 text-primary" />
</Label>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
<CardSelect
value={selectedComponents}
onChange={handleComponentChange}
options={filteredComponents.map(c => ({
id: c.path,
name: c.fileName,
description: c.summary,
url: c.path
}))}
showCheckbox={true}
containerClassName="space-y-1.5"
className="group p-3 border-l-2 border-l-transparent hover:border-l-primary hover:bg-accent/50 transition-all"
renderExtra={(option) => (
<p className="text-xs text-muted-foreground/70 mt-1.5 break-all line-clamp-2 font-mono">
{option.url}
</p>
)}
/>
</div>
</>
) : searchQuery ? (
<div className="text-center py-12 px-4 bg-muted/20">
<Search className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm font-medium text-foreground mb-1"></p>
<p className="text-xs text-muted-foreground">
使
</p>
</div>
) : (
<div className="text-center py-12 px-4 bg-muted/20">
<Package className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm font-medium text-foreground mb-1">UI组件</p>
<p className="text-xs text-muted-foreground">
registry导入组件
</p>
</div>
)}
</div>
<Separator className="flex-shrink-0" />
{/* AI 提示词输入框 */}
<div className="space-y-3 flex-shrink-0">
<Label className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
AI
</Label>
<PromptInput
multiple
accept="image/*"
onSubmit={handleSubmit}
className="w-full"
>
<PromptInputBody>
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
<PromptInputTextarea
placeholder="描述你想要的UI效果例如创建一个登录表单包含用户名和密码输入框以及一个提交按钮..."
rows={4}
className="resize-none"
/>
</PromptInputBody>
<PromptInputToolbar>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments label="添加图片" />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit status={status} />
</PromptInputToolbar>
</PromptInput>
</div>
</CardContent>
</Card>
{/* 右侧:代码预览 */}
<div className="lg:col-span-2 flex flex-col min-h-0">
<Card className="flex-1 flex flex-col min-h-0 shadow-lg border-2 border-border/50 min-h-160">
<CardHeader className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Code2 className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<CardTitle className="text-xl"></CardTitle>
<CardDescription className="text-xs">
</CardDescription>
</div>
{generatedCode && (
<Badge variant="secondary" className="text-xs gap-1">
<Eye className="h-3 w-3" />
</Badge>
)}
</div>
<Separator />
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-hidden">
{status === "streaming" || generatedCode ? (
<CodeEditorPreview
code={generatedCode || undefined}
scope={componentScope}
editorTitle="代码编辑器"
previewTitle="实时预览"
enableFullscreen={true}
loading={status === "streaming"}
/>
) : (
<div className="h-full flex items-center justify-center bg-muted/20">
<div className="text-center space-y-4 px-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
</p>
<p className="text-xs text-muted-foreground max-w-md">
使AI将为你生成可预览的代码
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* 添加组件Sheet */}
<AddComponentSheet open={addSheetOpen} onOpenChange={setAddSheetOpen} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import "./dev-theme.css";
export default function DevLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
</>
);
}

View File

@@ -0,0 +1,88 @@
/**
* 智能体配置
*/
export interface AgentTool {
id: string
name: string
description: string
}
export interface AgentModel {
id: string
name: string
logo: string
}
export interface AgentType {
id: string
name: string
description: string
defaultModel: string
defaultTools: string[]
availableTools: AgentTool[]
}
// 可用的模型列表
export const AVAILABLE_MODELS: AgentModel[] = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', logo: 'anthropic' },
{ id: 'claude-sonnet-4-5-20250929:thinking', name: 'Claude Sonnet 4.5 深度思考', logo: 'anthropic' },
{ id: 'gpt-4.1', name: 'GPT-4.1', logo: 'openai' },
]
// 智能体类型配置
export const AGENT_TYPES: AgentType[] = [
{
id: 'project-assistant',
name: '项目管家',
description: '(功能开发中,暂不可用)帮助您了解和规划项目',
defaultModel: 'claude-sonnet-4-5-20250929:thinking',
defaultTools: ['read-project-files'],
availableTools: [
// { id: 'read-project-files', name: '读取项目文件', description: '读取项目文件及其文件分析数据' },
],
},
{
id: 'casual-chat',
name: '随便聊聊',
description: '适用于和项目关联不大的一般性问题和对话',
defaultModel: 'gpt-4.1',
defaultTools: [],
availableTools: [
],
},
// {
// id: 'tech-selection',
// name: '技术选型',
// description: '帮助进行技术栈选择和架构设计',
// defaultModel: 'gpt-4.1',
// defaultTools: ['web-search', 'tech-analyzer'],
// availableTools: [
// { id: 'web-search', name: '网络搜索', description: '搜索技术文档和最佳实践' },
// { id: 'tech-analyzer', name: '技术分析器', description: '分析技术栈的优劣' },
// { id: 'benchmark-tool', name: '性能基准', description: '对比不同技术的性能' },
// ],
// },
// {
// id: 'requirement',
// name: '需求沟通',
// description: '协助需求分析和功能设计',
// defaultModel: 'gpt-4.1',
// defaultTools: ['diagram-generator', 'requirement-analyzer'],
// availableTools: [
// { id: 'diagram-generator', name: '图表生成器', description: '生成流程图和架构图' },
// { id: 'requirement-analyzer', name: '需求分析器', description: '分析和拆解需求' },
// { id: 'user-story-writer', name: '用户故事编写', description: '编写用户故事' },
// ],
// },
]
// 根据智能体类型ID获取配置
export function getAgentTypeById(id: string): AgentType | undefined {
return AGENT_TYPES.find((type) => type.id === id)
}
// 根据模型ID获取模型信息
export function getModelById(id: string): AgentModel | undefined {
return AVAILABLE_MODELS.find((model) => model.id === id)
}

View File

@@ -0,0 +1,584 @@
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { trpc } from '@/lib/trpc'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import {
Timeline,
TimelineEmpty,
TimelineItem,
TimelineConnector,
TimelineNode,
TimelineContent,
TimelineHeader,
TimelineTitleArea,
TimelineTitle,
TimelineBadge,
TimelineActions,
TimelineTimestamp,
TimelineDescription,
TimelineMetadata,
} from '@/components/data-details'
import {
AdvancedSelect,
SelectPopover,
SelectTrigger,
SelectContent,
SelectItemList,
SelectedName,
} from '@/components/common/advanced-select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import React from 'react'
import { keepPreviousData } from '@tanstack/react-query'
/**
* 版本控制组件
*/
export function VersionControl({ isOpen }: { isOpen: boolean }) {
const [commitMessage, setCommitMessage] = React.useState('')
const [selectedBranch, setSelectedBranch] = React.useState<string>('')
const [showCommitDialog, setShowCommitDialog] = React.useState(false)
const [commitLimit, setCommitLimit] = React.useState(10)
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null)
const [confirmAction, setConfirmAction] = React.useState<{
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset'
commitId?: string
message?: string
title?: string
description?: string
} | null>(null)
const scrollViewportRef = React.useRef<HTMLDivElement>(null)
// 查询分支列表
const { data: branches, refetch: refetchBranches, isLoading: branchesLoading } = trpc.devPanel!.getBranches.useQuery(undefined, {
enabled: isOpen,
})
// 查询当前分支
const { data: currentBranchData, isLoading: currentBranchLoading } = trpc.devPanel!.getCurrentBranch.useQuery(undefined, {
enabled: isOpen,
})
// 初始化选中的分支:优先级为 isCurrent > master > main > 第一个分支
React.useEffect(() => {
if (!branches || branches.length === 0 || selectedBranch) return
const initialBranch =
branches.find(b => b.isCurrent)?.name ||
branches.find(b => b.name === 'master')?.name ||
branches.find(b => b.name === 'main')?.name ||
branches[0].name
setSelectedBranch(initialBranch)
}, [branches, selectedBranch])
// 查询提交历史(根据选中的分支)
const { data: commits, refetch: refetchCommits, isLoading: commitsLoading, isFetching } = trpc.devPanel!.getCommitHistory.useQuery(
{ limit: commitLimit, branchName: selectedBranch },
{
enabled: isOpen,
placeholderData: keepPreviousData
}
)
// 初始加载完成后设置标志
React.useEffect(() => {
if (!commitsLoading && commits) {
setIsInitialLoad(false)
}
}, [commitsLoading, commits])
// 查询是否有未提交的更改
const { data: hasChangesData, refetch: refetchHasChanges, isLoading: hasChangesLoading } = trpc.devPanel!.hasUncommittedChanges.useQuery(undefined, {
enabled: isOpen,
})
// 创建提交mutation
const createCommitMutation = trpc.devPanel!.createCommit.useMutation({
onSuccess: (data) => {
toast.success(data.message)
setCommitMessage('')
setShowCommitDialog(false)
setCommitType(null)
refetchCommits()
refetchHasChanges()
},
onError: (error) => {
toast.error(error.message)
setCommitType(null)
},
})
// 切换到指定提交mutation
const checkoutCommitMutation = trpc.devPanel!.checkoutCommit.useMutation({
onSuccess: (data) => {
toast.success(data.message)
refetchBranches()
refetchCommits()
setConfirmAction(null)
},
onError: (error) => {
toast.error(error.message)
},
})
// 切换到指定分支mutation
const checkoutBranchMutation = trpc.devPanel!.checkoutBranch.useMutation({
onSuccess: (data) => {
toast.success(data.message)
refetchBranches()
refetchCommits()
setConfirmAction(null)
},
onError: (error) => {
toast.error(error.message)
},
})
// 反转提交mutation
const revertCommitMutation = trpc.devPanel!.revertCommit.useMutation({
onSuccess: (data) => {
toast.success(data.message)
refetchCommits()
setConfirmAction(null)
},
onError: (error) => {
toast.error(error.message)
},
})
// 强制回滚mutation
const resetToCommitMutation = trpc.devPanel!.resetToCommit.useMutation({
onSuccess: (data) => {
toast.success(data.message)
refetchCommits()
setConfirmAction(null)
},
onError: (error) => {
toast.error(error.message)
},
})
// 处理分支选择(仅用于查看历史,不切换实际分支)
const handleBranchChange = (branchName: string | null) => {
if (!branchName) return
setSelectedBranch(branchName)
}
// 处理提交
const handleCommit = (amend: boolean = false) => {
if (!commitMessage.trim()) {
toast.error('请输入提交信息')
return
}
setCommitType(amend ? 'amend' : 'normal')
createCommitMutation.mutate({ message: commitMessage, amend })
}
// 处理切换到指定提交
const handleCheckoutCommit = (commitId: string, message: string, isFirstCommit: boolean = false) => {
// 如果是第一个节点,显示特殊提示,并且切换到这个分支
if (isFirstCommit) {
setConfirmAction({
type: 'checkout-branch',
commitId,
message,
title: '切换到此分支',
description: '这是该分支的最新版本。切换到此分支后,您可以继续进行开发和提交新的更改。',
})
} else {
setConfirmAction({
type: 'checkout',
commitId,
message,
title: '切换到指定提交',
description: '确定要切换到此提交吗?这将使代码回到该提交时的状态,但如果要继续编写代码和提交请切换回最新的提交节点。',
})
}
}
// 处理反转提交
const handleRevert = (commitId: string, message: string) => {
setConfirmAction({
type: 'revert',
commitId,
message,
title: '反转提交',
description: '确定要反转该提交吗?这将创建一个与该提交操作相反的提交。',
})
}
// 处理强制回滚
const handleReset = (commitId: string, message: string) => {
setConfirmAction({
type: 'reset',
commitId,
message,
title: '强制回滚到指定提交',
description: '⚠️ 警告:确定要强制回滚到此提交吗?这将永久删除该提交之后的所有提交,此操作不可恢复!',
})
}
// 处理滚动事件
const handleScroll = React.useCallback(() => {
const viewport = scrollViewportRef.current
if (!viewport || isFetching || isLoadingMore) return
const { scrollTop, scrollHeight, clientHeight } = viewport
// 当滚动到底部附近100px时加载更多
if (scrollTop + clientHeight >= scrollHeight - 100) {
if (commits && commits.length >= commitLimit) {
setIsLoadingMore(true)
setCommitLimit(prev => prev + 10)
}
}
}, [isFetching, commits, commitLimit, isLoadingMore])
// 监听加载状态变化,加载完成后重置isLoadingMore
React.useEffect(() => {
if (!isFetching && isLoadingMore) {
setIsLoadingMore(false)
}
}, [isFetching, isLoadingMore])
// 监听滚动事件
React.useEffect(() => {
const viewport = scrollViewportRef.current
if (!viewport) return
viewport.addEventListener('scroll', handleScroll)
return () => viewport.removeEventListener('scroll', handleScroll)
}, [handleScroll])
// 执行确认的操作
const executeConfirmedAction = () => {
if (!confirmAction) return
switch (confirmAction.type) {
case 'checkout':
if (confirmAction.commitId) {
checkoutCommitMutation.mutate({ commitId: confirmAction.commitId })
}
break
case 'checkout-branch':
// 切换到分支(使用选中的分支名称)
checkoutBranchMutation.mutate({ branchName: selectedBranch })
break
case 'revert':
if (confirmAction.commitId) {
revertCommitMutation.mutate({ commitId: confirmAction.commitId })
}
break
case 'reset':
if (confirmAction.commitId) {
resetToCommitMutation.mutate({ commitId: confirmAction.commitId })
}
break
}
}
// 手动刷新所有数据
const handleRefresh = () => {
refetchBranches()
refetchCommits()
refetchHasChanges()
}
const hasChanges = hasChangesData?.hasChanges
const isLoading = branchesLoading || currentBranchLoading || commitsLoading || hasChangesLoading
const branchOptions = React.useMemo(
() =>
branches?.map((b) => ({
id: b.name,
name: b.isCurrent ? `${b.name} (当前分支)` : b.name,
})) || [],
[branches]
)
return (
<div className="flex h-full flex-col space-y-4">
{/* 分支选择器和操作按钮 */}
<div className="space-y-2 shrink-0">
<div className="flex items-center gap-2">
{/* 左半部分:分支选择器 */}
<div className="flex items-center gap-2 flex-1">
<Label className="shrink-0 flex-none"></Label>
<div className="flex-1 min-w-0">
{branchesLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<AdvancedSelect
value={selectedBranch}
onChange={handleBranchChange}
options={branchOptions}
>
<SelectPopover>
<SelectTrigger placeholder="选择分支">
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectItemList emptyText="未找到分支" />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
)}
</div>
</div>
{/* 右半部分Commit按钮和刷新按钮 */}
<div className="flex items-center gap-2 flex-1 justify-end">
<Button
onClick={() => setShowCommitDialog(true)}
disabled={!hasChanges || hasChangesLoading}
variant="default"
>
<GitCommit className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={handleRefresh}
disabled={isLoading}
>
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
</Button>
</div>
</div>
{hasChanges && (
<div className="flex items-center gap-2 text-xs text-amber-600">
<AlertTriangle className="h-3 w-3" />
<span></span>
</div>
)}
{selectedBranch !== currentBranchData?.branch && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span> <strong>{selectedBranch}</strong> </span>
</div>
)}
</div>
<Separator className="shrink-0" />
{/* 提交历史 */}
<div className="flex-1 min-h-0">
<ScrollArea
className="h-full pr-4"
viewportRef={scrollViewportRef}
onPointerDown={(e) => e.stopPropagation()}
>
{isInitialLoad && commitsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-16 w-full" />
</div>
))}
</div>
) : (!commits || commits.length === 0) ? (
<TimelineEmpty></TimelineEmpty>
) : (
<Timeline>
{commits.map((commit, index) => {
const isAfterHead = commit.isAfterHead
const isFirstCommit = index === 0
return (
<TimelineItem key={commit.shortId} className='select-text'>
<TimelineConnector className={cn(isAfterHead && 'bg-muted-foreground/30')} />
<TimelineNode
icon={GitCommitIcon}
className={cn(isAfterHead && 'border-muted-foreground/30')}
iconClassName={cn(isAfterHead && 'text-muted-foreground/50')}
/>
<TimelineContent>
<TimelineHeader>
<TimelineTitleArea className={cn(isAfterHead && 'opacity-50')}>
<TimelineTitle className="leading-tight whitespace-normal">{commit.message}</TimelineTitle>
</TimelineTitleArea>
<TimelineActions>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 text-xs">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleCheckoutCommit(commit.shortId, commit.message, isFirstCommit)}
disabled={hasChanges}
>
<GitBranch className="mr-2 h-4 w-4" />
{isFirstCommit ? '切换到此分支' : '切换到此提交'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleRevert(commit.shortId, commit.message)}
>
<CornerRightUp className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => handleReset(commit.shortId, commit.message)}
>
<RotateCcw className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TimelineActions>
</TimelineHeader>
<TimelineTimestamp timestamp={commit.date} className={cn(isAfterHead && 'opacity-50')} />
<TimelineDescription className={cn(isAfterHead && 'opacity-50')}>
Commit ID: <TimelineBadge variant="secondary">{commit.shortId}</TimelineBadge>
</TimelineDescription>
<TimelineMetadata
className={cn("flex flex-row items-center gap-4 space-y-0", isAfterHead && 'opacity-50')}
items={[
{ label: '文件变更', value: `${commit.filesChanged}` },
{
label: '新增行',
value: `+${commit.insertions}`,
valueClassName: 'text-green-600',
},
{
label: '删除行',
value: `-${commit.deletions}`,
valueClassName: 'text-red-600',
},
]}
/>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
)}
{/* 加载更多指示器 */}
{isLoadingMore && !isInitialLoad && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">...</span>
</div>
)}
</ScrollArea>
</div>
{/* Commit对话框 */}
<Dialog open={showCommitDialog} onOpenChange={setShowCommitDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="commit-message-dialog"></Label>
<Textarea
id="commit-message-dialog"
placeholder="输入提交信息..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={4}
/>
</div>
</div>
<DialogFooter className="flex-row justify-end space-x-2">
<Button
variant="outline"
onClick={() => {
setShowCommitDialog(false)
setCommitMessage('')
}}
>
</Button>
<Button
variant="secondary"
onClick={() => handleCommit(true)}
disabled={!commitMessage.trim() || createCommitMutation.isPending}
>
{createCommitMutation.isPending && commitType === 'amend' ? '修订提交中...' : '修订提交'}
</Button>
<Button
onClick={() => handleCommit(false)}
disabled={!commitMessage.trim() || createCommitMutation.isPending}
>
{createCommitMutation.isPending && commitType === 'normal' ? '提交中...' : '确认提交'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 确认对话框 */}
<AlertDialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
<AlertDialogDescription>
{confirmAction?.commitId && confirmAction?.message && (
<>
<span className="font-bold block mb-2">
{confirmAction.commitId}: {confirmAction.message}
</span>
</>
)}
{confirmAction?.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={executeConfirmedAction}
className={confirmAction?.type === 'reset' ? 'bg-destructive hover:bg-destructive/90' : ''}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,407 @@
'use client'
import { toast } from 'sonner'
import copy from 'copy-to-clipboard'
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import { Message, MessageContent, MessageResponse, MessageAttachments, MessageAttachment } from '@/components/ai-elements/message'
import { Actions, Action } from '@/components/ai-elements/actions'
import {
PromptInput,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
PromptInputSubmit,
PromptInputAttachments,
PromptInputAttachment,
PromptInputActionMenu,
PromptInputActionMenuTrigger,
PromptInputActionMenuContent,
PromptInputActionAddAttachments,
PromptInputButton,
} from '@/components/ai-elements/prompt-input'
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorLogo,
ModelSelectorName,
ModelSelectorTrigger,
} from '@/components/ai-elements/model-selector'
import { MessageSquareIcon, BotIcon, CopyIcon, RefreshCcwIcon, CheckIcon, WrenchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useState, useEffect } from 'react'
import { AGENT_TYPES, AVAILABLE_MODELS, getAgentTypeById, getModelById } from './agents-config'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { DialogDescription } from '@/components/ui/dialog'
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
import { ScrollArea } from '@/components/ui/scroll-area'
export function DevAIChat() {
// 智能体、模型和工具的状态管理
const [selectedAgent, setSelectedAgent] = useState(AGENT_TYPES[0].id)
const [selectedModel, setSelectedModel] = useState(AGENT_TYPES[0].defaultModel)
const [selectedTools, setSelectedTools] = useState<string[]>(AGENT_TYPES[0].defaultTools)
const [agentSelectorOpen, setAgentSelectorOpen] = useState(false)
const [modelSelectorOpen, setModelSelectorOpen] = useState(false)
const [toolSelectorOpen, setToolSelectorOpen] = useState(false)
const { messages, status, sendMessage, regenerate } = useChat({
transport: new DefaultChatTransport({
api: '/api/dev/ai-chat',
}),
})
// 获取当前选中的智能体配置
const currentAgent = getAgentTypeById(selectedAgent)
const currentModel = getModelById(selectedModel)
// 当智能体切换时,自动选中默认的模型和工具
useEffect(() => {
const agent = getAgentTypeById(selectedAgent)
if (agent) {
setSelectedModel(agent.defaultModel)
setSelectedTools(agent.defaultTools)
}
}, [selectedAgent])
// 切换工具选择
const toggleTool = (toolId: string) => {
setSelectedTools(prev =>
prev.includes(toolId)
? prev.filter(id => id !== toolId)
: [...prev, toolId]
)
}
// 复制文本到剪贴板
const handleCopy = (text: string) => {
const success = copy(text)
if (success) {
toast.success('已复制到剪贴板')
} else {
toast.error('复制失败')
}
}
// 重新生成回复
const handleRegenerate = () => {
regenerate({
body: {
agent: selectedAgent,
model: selectedModel,
tools: selectedTools,
},
})
}
return (
<div className="flex h-[98%] flex-col">
{/* 消息列表区域 - 占据剩余空间 */}
<ScrollArea className="h-full" >
<Conversation className="flex-1 min-h-0">
<ConversationContent>
{messages.length === 0 ? (
<ConversationEmptyState
title="开始对话"
description="与AI助手对话完成各种开发任务"
icon={<MessageSquareIcon className="size-8" />}
/>
) : (
messages.map((message, messageIndex) => {
const isLastMessage = messageIndex === messages.length - 1
const isAssistant = message.role === 'assistant'
const messageText = message.parts
.filter(part => part.type === 'text')
.map(part => part.type === 'text' ? part.text : '')
.join('')
return (
<div key={message.id} className="group">
<Message from={message.role}>
<MessageContent className='select-text'>
{/* 先渲染附件 */}
{message.parts.some(part => part.type === 'file') && (
<MessageAttachments>
{message.parts
.filter(part => part.type === 'file')
.map((part, i) => (
<MessageAttachment
key={`${message.id}-file-${i}`}
data={part}
/>
))}
</MessageAttachments>
)}
{/* 然后渲染其他内容 */}
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
);
case 'reasoning':
return (
<Reasoning
key={`${message.id}-${i}`}
className="w-full"
isStreaming={status === 'streaming' && i === message.parts.length - 1 && message.id === messages.at(-1)?.id}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
case 'file':
// 文件已经在上面单独渲染了
return null;
default:
return null;
}
})}
</MessageContent>
</Message>
{/* 操作按钮 - 悬停时显示 */}
<Actions className={cn(
"mt-2 opacity-0 group-hover:opacity-100 transition-opacity",
message.role === 'user' ? 'mr-1 justify-end' : 'ml-1'
)}>
{/* 复制按钮 - 所有消息都有 */}
<Action
tooltip="复制"
label="复制"
onClick={() => handleCopy(messageText)}
>
<CopyIcon className="size-4" />
</Action>
{/* 重新生成按钮 - 仅最后一条AI消息显示 */}
{isAssistant && isLastMessage && (
<Action
tooltip="重新生成"
label="重新生成"
onClick={handleRegenerate}
disabled={status === 'streaming'}
>
<RefreshCcwIcon className="size-4" />
</Action>
)}
</Actions>
</div>
)
})
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</ScrollArea>
{/* 输入框区域 - 固定在底部 */}
<div className="border-t bg-background">
<PromptInput
accept="image/*"
multiple
maxFiles={5}
maxFileSize={50 * 1024 * 1024} // 50MB
onError={(error) => {
if ('message' in error) {
console.error('文件上传错误:', error.message)
// 根据错误类型显示不同的提示
switch (error.code) {
case 'max_file_size':
toast.error('文件过大', {
description: '单个文件大小不能超过 10MB请压缩后重试'
})
break
case 'max_files':
toast.error('文件数量超限', {
description: '最多只能上传 5 个文件'
})
break
case 'accept':
toast.error('文件类型不支持', {
description: '仅支持图片文件'
})
break
default:
toast.error('文件上传失败', {
description: error.message
})
}
}
}}
onSubmit={async (message) => {
sendMessage(
{
text: message.text || '',
files: message.files,
},
{
body: {
agent: selectedAgent,
model: selectedModel,
tools: selectedTools,
},
}
)
}}
className="max-h-[50vh]"
>
{/* 附件预览区域 */}
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
{/* 文本输入框 */}
<PromptInputTextarea
placeholder="输入消息或上传图片..."
className="min-h-[60px] max-h-[40vh] resize-none"
/>
{/* 工具栏 */}
<PromptInputToolbar>
<PromptInputTools className='flex-wrap'>
{/* 智能体选择器 */}
<Popover open={agentSelectorOpen} onOpenChange={setAgentSelectorOpen}>
<PopoverTrigger asChild>
<PromptInputButton>
<BotIcon className="size-4 text-muted-foreground" />
<span>{currentAgent?.name}</span>
</PromptInputButton>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup>
{AGENT_TYPES.map((agent) => (
<CommandItem
key={agent.id}
value={agent.id}
onSelect={() => {
setSelectedAgent(agent.id)
setAgentSelectorOpen(false)
}}
>
<div className="flex flex-1 flex-col">
<span className="font-medium text-sm">{agent.name}</span>
<span className="text-muted-foreground text-xs">{agent.description}</span>
</div>
{selectedAgent === agent.id && (
<CheckIcon className="ml-2 size-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 模型选择器 */}
<ModelSelector open={modelSelectorOpen} onOpenChange={setModelSelectorOpen}>
<ModelSelectorTrigger asChild>
<PromptInputButton>
<ModelSelectorLogo provider={currentModel?.logo || 'unknown'} />
<ModelSelectorName>{currentModel?.name}</ModelSelectorName>
</PromptInputButton>
</ModelSelectorTrigger>
<ModelSelectorContent title="选择模型">
<VisuallyHidden><DialogDescription></DialogDescription></VisuallyHidden>
<ModelSelectorInput placeholder="搜索模型..." />
<ModelSelectorList>
<ModelSelectorEmpty></ModelSelectorEmpty>
<ModelSelectorGroup>
{AVAILABLE_MODELS.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
onSelect={() => {
setSelectedModel(model.id)
setModelSelectorOpen(false)
}}
>
<ModelSelectorLogo provider={model.logo} />
<ModelSelectorName>{model.name}</ModelSelectorName>
{selectedModel === model.id && (
<CheckIcon className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
{/* 工具选择器 */}
{currentAgent && currentAgent.availableTools.length > 0 && (
<Popover open={toolSelectorOpen} onOpenChange={setToolSelectorOpen}>
<PopoverTrigger asChild>
<PromptInputButton>
<WrenchIcon className="size-3 text-muted-foreground" />
<span>{selectedTools.length} </span>
</PromptInputButton>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0" align="start">
<Command>
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup heading="可用工具">
{currentAgent.availableTools.map((tool) => (
<CommandItem
key={tool.id}
value={tool.id}
onSelect={() => toggleTool(tool.id)}
>
<div className="flex flex-1 flex-col">
<span className="font-medium text-sm">{tool.name}</span>
<span className="text-muted-foreground text-xs">{tool.description}</span>
</div>
{selectedTools.includes(tool.id) && (
<CheckIcon className="ml-2 size-4 text-primary" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</PromptInputTools>
{/* 右侧按钮组 */}
<div className="flex items-center gap-2">
{/* 附件上传菜单 */}
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments label="添加图片" />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{/* 发送按钮 */}
<PromptInputSubmit status={status} />
</div>
</PromptInputToolbar>
</PromptInput>
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import * as React from 'react'
import { GitBranch } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { VersionControl } from './components/version-control'
/**
* 开发清单组件
* 包含版本控制功能
*/
export function DevChecklist({ isOpen }: { isOpen: boolean }) {
return (
<Tabs defaultValue="version-control" className="flex h-[98%] flex-col">
<TabsList className="w-fit">
<TabsTrigger value="version-control">
<GitBranch className="mr-2 h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="version-control" className="flex-1 min-h-0 mt-4">
<VersionControl isOpen={isOpen} />
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import * as React from 'react'
/**
* DevPanel Context 类型定义
*/
interface DevPanelContextType {
terminalLoaded: boolean
setTerminalLoaded: (loaded: boolean) => void
}
/**
* DevPanel Context
*/
const DevPanelContext = React.createContext<DevPanelContextType | undefined>(
undefined
)
/**
* DevPanel Provider Props
*/
interface DevPanelProviderProps {
children: React.ReactNode
}
/**
* DevPanel Provider
* 管理开发面板的全局状态,包括终端加载状态等
*/
export function DevPanelProvider({ children }: DevPanelProviderProps) {
const [terminalLoaded, setTerminalLoaded] = React.useState(false)
const value = React.useMemo(
() => ({
terminalLoaded,
setTerminalLoaded,
}),
[terminalLoaded]
)
return (
<DevPanelContext.Provider value={value}>
{children}
</DevPanelContext.Provider>
)
}
/**
* 使用 DevPanel Context 的 Hook
* @throws {Error} 如果在 DevPanelProvider 外部使用
*/
export function useDevPanel() {
const context = React.useContext(DevPanelContext)
if (context === undefined) {
throw new Error('useDevPanel must be used within a DevPanelProvider')
}
return context
}

View File

@@ -0,0 +1,47 @@
'use client'
import { useState } from 'react'
import { Code2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { TripleColumnAdaptiveDrawer } from '@/components/common/triple-column-adaptive-drawer'
import { DevTools } from './dev-tools'
import { DevPanelProvider } from './dev-panel-provider'
import { DevAIChat } from './dev-ai-chat'
import { DevChecklist } from './dev-checklist'
export function DevPanel() {
const [open, setOpen] = useState(false)
return (
<DevPanelProvider>
<TripleColumnAdaptiveDrawer
trigger={
<Button variant="ghost" size="icon" title="开发者工具">
<Code2 className="h-5 w-5" />
</Button>
}
drawerTitle="开发者工具"
drawerDescription="开发环境专用工具面板"
open={open}
onOpenChange={setOpen}
columns={[
{
id: 'checklist',
title: '开发清单',
content: <DevChecklist isOpen={open} />,
},
{
id: 'ai-chat',
title: '对话',
content: <DevAIChat />,
},
{
id: 'dev-tools',
title: '开发工具',
content: <DevTools />,
},
]}
/>
</DevPanelProvider>
)
}

View File

@@ -0,0 +1,252 @@
'use client'
import * as React from 'react'
import { Terminal, Maximize2, Plug, Plus, ChevronLeft, ChevronRight, X, Columns2, ArrowLeftRight, List, Copy, LucideIcon, HelpCircle, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useDevPanel } from './dev-panel-provider'
import { Kbd } from '@/components/ui/kbd'
import { PreviewCard } from '@/components/common/preview-card'
import { trpc } from '@/lib/trpc'
import { toast } from 'sonner'
// 终端操作按钮配置
interface TerminalAction {
label: string
description: string
icon: LucideIcon
command: string
key?: string
iconRotate?: boolean
}
const WINDOW_ACTIONS: TerminalAction[] = [
{ label: '新建窗口', description: '在当前session中创建一个新的窗口', icon: Plus, command: 'new-window', key: 'C' },
{ label: '上一个', description: '切换到上一个窗口', icon: ChevronLeft, command: 'previous-window', key: 'P' },
{ label: '下一个', description: '切换到下一个窗口', icon: ChevronRight, command: 'next-window', key: 'N' },
]
const PANE_ACTIONS: TerminalAction[] = [
{ label: '水平分割', description: '将当前面板水平分割为上下两个面板', icon: Columns2, command: 'split-window -v', key: '"', iconRotate: true },
{ label: '垂直分割', description: '将当前面板垂直分割为左右两个面板', icon: Columns2, command: 'split-window -h', key: '%' },
{ label: '切换面板', description: '在多个面板之间循环切换', icon: ArrowLeftRight, command: 'send-keys "tmux select-pane -t :.+" ^M', key: 'O' },
{ label: '复制模式', description: '进入复制模式,可以滚动查看历史输出,按 q 退出', icon: Copy, command: 'copy-mode', key: '[' },
]
const HELP_ACTIONS: TerminalAction[] = [
{ label: '列出会话', description: '显示所有tmux会话列表', icon: List, command: 'send-keys "tmux ls" ^M' },
{ label: '列出窗口', description: '显示当前会话的所有窗口', icon: List, command: 'send-keys "tmux list-windows" ^M', key: 'W' },
{ label: '列出快捷键', description: '显示所有tmux快捷键绑定', icon: HelpCircle, command: 'send-keys "tmux list-keys" ^M' },
]
/**
* 开发工具组件
* 提供终端等开发工具的标签页界面,支持全屏展示
*/
export function DevTools() {
const [fullscreenOpen, setFullscreenOpen] = React.useState(false)
const [hoveredAction, setHoveredAction] = React.useState<TerminalAction | null>(null)
const { terminalLoaded, setTerminalLoaded } = useDevPanel()
// 发送tmux命令的mutation
const sendCommand = trpc.devPanel!.sendTmuxCommand.useMutation({
onError: (error) => {
toast.error(`命令执行失败: ${error.message}`)
},
})
// 执行tmux命令
const executeTmuxCommand = (command?: string) => {
if (command) {
sendCommand.mutate({ command })
}
}
// 渲染操作按钮
const renderActionButton = (action: TerminalAction) => {
const Icon = action.icon
return (
<Button
key={action.command}
variant="outline"
size="sm"
onClick={() => executeTmuxCommand(action.command)}
onMouseEnter={() => setHoveredAction(action)}
onMouseLeave={() => setHoveredAction(null)}
disabled={!terminalLoaded || sendCommand.isPending}
className="gap-1.5"
>
<Icon className={`h-3.5 w-3.5 ${action.iconRotate ? 'rotate-90' : ''}`} />
{action.label}
{action.key && <Kbd className="ml-1">{action.key}</Kbd>}
</Button>
)
}
// 终端控制面板内容
const terminalControlsContent = (
<div className="space-y-3">
{/* 提示信息 */}
<div className="text-xs text-muted-foreground">
使 <Kbd>Ctrl</Kbd> + <Kbd>B</Kbd> tmux控制模式
</div>
{/* 窗口管理 */}
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="flex flex-wrap gap-2">
{WINDOW_ACTIONS.map(renderActionButton)}
</div>
</div>
{/* 面板管理 */}
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="flex flex-wrap gap-2">
{PANE_ACTIONS.map(renderActionButton)}
</div>
</div>
{/* 帮助 */}
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="flex flex-wrap gap-2">
{HELP_ACTIONS.map(renderActionButton)}
<Button
variant="outline"
size="sm"
onClick={() => {
const port = process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'
window.open(`http://localhost:${port}`, '_blank')
}}
disabled={!terminalLoaded}
className="gap-1.5"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 实时描述区域 */}
<div className="pt-2 border-t border-border">
<div className="text-xs text-muted-foreground">
{hoveredAction ? (
<div className="space-y-1">
<div>{hoveredAction.description}</div>
</div>
) : (
<div></div>
)}
</div>
</div>
</div>
)
// 渲染工具内容
const renderToolsContent = (isFullscreen = false) => (
<Tabs defaultValue="terminal" className="w-full h-full flex flex-col">
<div className="flex items-center justify-between">
<TabsList>
<PreviewCard
title="终端控制"
description={terminalControlsContent}
side="top"
align="start"
className="w-[500px]"
disabled={!terminalLoaded}
>
<TabsTrigger value="terminal" className={terminalLoaded ? "font-bold" : ""}>
{terminalLoaded ? (
<Plug className="mr-2 h-4 w-4" />
) : (
<Terminal className="mr-2 h-4 w-4" />
)}
<span className={terminalLoaded ? "bg-gradient-to-r from-blue-400 to-blue-500 bg-clip-text text-transparent" : ""}>
</span>
</TabsTrigger>
</PreviewCard>
</TabsList>
{!isFullscreen && (
<Button
variant="ghost"
size="sm"
className="h-8 px-3"
onClick={() => setFullscreenOpen(true)}
>
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
</Button>
)}
</div>
<TabsContent value="terminal" className="flex-1 mt-0 pt-4 h-full">
<div className="w-full h-full">
{terminalLoaded ? (
<iframe
src={`http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`}
className="w-full h-full border-0 rounded-md bg-black"
title="开发终端"
/>
) : (
<div className="h-full flex items-center justify-center bg-muted/20 rounded-md">
<div className="text-center space-y-4 px-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
<Terminal className="h-8 w-8 text-primary" />
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground max-w-md">
Web终端
</p>
</div>
<Button
onClick={() => setTerminalLoaded(true)}
className="gap-2"
>
<Plug className='w-4 h-4' />
</Button>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
)
return (
<>
{renderToolsContent()}
{/* 全屏对话框 */}
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
<DialogContent className="p-0" variant="fullscreen">
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
<DialogTitle className="px-6 text-base"></DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<DialogBody className="overflow-hidden p-6">
{renderToolsContent(true)}
</DialogBody>
<DialogFooter className="px-6 py-4 border-t border-border">
<DialogClose asChild>
<Button type="button"></Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,5 @@
/**
* 开发者面板本质是个drawer组件而不是单独的页面在这个文件中导出以便使用
*/
export { DevPanel } from './dev-panel'

View File

@@ -0,0 +1,19 @@
"use client";
export default function ContainerPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-4"></h2>
<p className="text-muted-foreground">
TODO
</p>
</div>
{/* 这里可以添加完整页面的测试和展示 */}
<div className="space-y-4">
{/* 示例区域 */}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
export default function RunPageLayout({
children,
}: {
children: React.ReactNode;
}) {
return <SubMenuLayout parentHref="/dev/run">{children}</SubMenuLayout>;
}

View File

@@ -0,0 +1,5 @@
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
export default function RunPage() {
return <SubMenuRedirect parentHref="/dev/run" />;
}

View File

@@ -0,0 +1,26 @@
'use client'
import React from 'react'
import { Button } from '@/components/ui/button'
import { signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
export default function ForbiddenPage() {
const router = useRouter()
return (
<div className="flex flex-col items-center justify-center h-full">
<h1 className="text-3xl font-bold mb-4">403 </h1>
<p className="mb-6">访</p>
<Button
onClick={async() => {
// 重定向到登录页
await signOut({ redirect: false })
router.push('/login')
}}
className="px-4 py-2 bg-primary text-white rounded"
>
</Button>
</div>
)
}

32
src/app/(main)/layout.tsx Normal file
View File

@@ -0,0 +1,32 @@
// app/(main)/layout.tsx
import { MainLayout } from '@/components/layout/main-layout'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/server/auth'
import { menuItems, filterMenuItemsByPermission } from '@/constants/menu'
export default async function MainAppLayout({
children,
}: {
children: React.ReactNode;
}) {
// 在服务器端获取用户会话和权限
const session = await getServerSession(authOptions)
const userPermissions = session?.user?.permissions ?? []
const isSuperAdmin = session?.user?.isSuperAdmin ?? false
// 在服务器端过滤菜单项
const filteredMenuItems = filterMenuItemsByPermission(
menuItems,
userPermissions,
isSuperAdmin
)
return (
<MainLayout
user={session?.user}
menuItems={filteredMenuItems}
>
{children}
</MainLayout>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
import React from 'react'
import { Button } from '@/components/ui/button'
import { useRouter } from 'next/navigation'
/**
* 404 Not Found 页面UI组件
* 当调用notFound()时会自动渲染此组件并返回404状态码
*/
export default function NotFound() {
const router = useRouter()
return (
<div className="flex flex-col items-center justify-center h-full">
<h1 className="text-3xl font-bold mb-4">404 </h1>
<p className="mb-6">访</p>
<Button
onClick={() => router.push('/')}
className="px-4 py-2 bg-primary text-white rounded"
>
</Button>
</div>
)
}

375
src/app/(main)/page.tsx Normal file
View File

@@ -0,0 +1,375 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Sparkles,
Zap,
Shield,
Layers,
Rocket,
Database,
Palette,
FileCode,
Terminal,
CheckCircle2,
ArrowRight,
BookOpen,
Upload,
Users,
Github,
} from 'lucide-react'
import { motion } from 'framer-motion'
import { SITE_NAME, SITE_VERSION, SITE_DESCRIPTION } from '@/constants/site'
import { trpc } from '@/lib/trpc'
import { WelcomeDialog } from './welcome'
const features = [
{
icon: Shield,
title: '身份认证与权限',
description: '内置完整的用户认证系统和细粒度权限控制,支持角色管理和复杂权限表达式',
color: 'text-blue-500',
bgColor: 'bg-blue-500/10'
},
{
icon: Palette,
title: '丰富UI组件',
description: '基于 shadcn/ui 和 Radix UI提供50+高质量组件,支持深色模式和响应式设计',
color: 'text-purple-500',
bgColor: 'bg-purple-500/10'
},
{
icon: Database,
title: '数据层完整方案',
description: 'Prisma ORM + PostgreSQL + Redis + MinIO提供完整的数据存储和文件管理方案',
color: 'text-green-500',
bgColor: 'bg-green-500/10'
},
{
icon: Zap,
title: '后台任务队列',
description: '基于 BullMQ 的任务队列系统,支持任务进度订阅和实时状态更新',
color: 'text-yellow-500',
bgColor: 'bg-yellow-500/10'
},
{
icon: Sparkles,
title: 'AI 智能体开发',
description: '集成 AI SDK提供对话组件和智能体开发工具快速构建AI驱动的应用',
color: 'text-pink-500',
bgColor: 'bg-pink-500/10'
},
{
icon: Upload,
title: '文件上传管理',
description: '客户端直传架构基于MinIO的文件存储方案支持预签名URL和权限控制',
color: 'text-orange-500',
bgColor: 'bg-orange-500/10'
}
]
const techStack = [
{ category: '前端框架', items: ['Next.js', 'React', 'TypeScript'] },
{ category: 'UI组件', items: ['Tailwind CSS', 'Radix UI', 'shadcn/ui', 'Framer Motion'] },
{ category: '后端架构', items: ['tRPC', 'Prisma', 'NextAuth'] },
{ category: '数据存储', items: ['PostgreSQL', 'Redis', 'MinIO'] },
{ category: '任务队列', items: ['BullMQ'] },
{ category: 'AI集成', items: ['AI SDK', 'ai-elements'] }
]
const quickStartSteps = [
{
icon: Terminal,
title: '克隆项目',
description: '从仓库克隆模板项目到本地',
code: 'git clone <repository-url>'
},
{
icon: FileCode,
title: '安装依赖',
description: '使用 pnpm 安装项目依赖',
code: 'pnpm install'
},
{
icon: Database,
title: '配置数据库',
description: '配置环境变量并初始化数据库',
code: 'pnpm run db:seed'
},
{
icon: Rocket,
title: '启动开发',
description: '启动开发服务器,开始构建应用',
code: 'pnpm run dev'
}
]
const highlights = [
{ icon: CheckCircle2, text: '开箱即用的完整功能' },
{ icon: CheckCircle2, text: '类型安全的全栈开发' },
{ icon: CheckCircle2, text: '现代化的开发体验' },
{ icon: CheckCircle2, text: '丰富的开发辅助工具' },
{ icon: CheckCircle2, text: '完整的用户管理系统' },
{ icon: CheckCircle2, text: '灵活的权限控制机制' }
]
export default function HomePage() {
const [showWelcome, setShowWelcome] = useState(false)
// 检查是否已显示过欢迎对话框
const { data: welcomeStatus } = trpc.global.checkWelcomeShown.useQuery()
const markWelcomeShown = trpc.global.markWelcomeShown.useMutation()
useEffect(() => {
if (welcomeStatus && !welcomeStatus.shown) {
setShowWelcome(true)
}
}, [welcomeStatus])
const handleWelcomeClose = async (open: boolean) => {
if (!open) {
setShowWelcome(false)
await markWelcomeShown.mutateAsync()
}
}
return (
<div className="min-h-screen">
<WelcomeDialog open={showWelcome} onOpenChange={handleWelcomeClose} />
{/* Hero Section */}
<section className="relative overflow-hidden bg-gradient-to-b from-background to-muted/20 px-4 py-20 md:py-32">
<div className="absolute inset-0 bg-grid-white/10 [mask-image:radial-gradient(white,transparent_85%)]" />
<div className="relative mx-auto max-w-6xl text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="mb-6 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
<span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
{SITE_NAME}
</span>
<sup className="ml-2 text-base font-normal text-muted-foreground md:text-lg">
{SITE_VERSION}
</sup>
</h1>
<p className="mx-auto mb-8 max-w-3xl text-lg text-muted-foreground md:text-xl">
{SITE_DESCRIPTION}
</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button size="lg" className="gap-2">
<Rocket className="h-5 w-5" />
</Button>
<Button size="lg" variant="outline" className="gap-2">
<BookOpen className="h-5 w-5" />
</Button>
<Button size="lg" variant="ghost" className="gap-2">
<Github className="h-5 w-5" />
GitHub
</Button>
</div>
</motion.div>
{/* Highlights */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mt-16 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6"
>
{highlights.map((item, index) => (
<div
key={index}
className="flex items-center gap-2 rounded-lg bg-background/50 p-3 text-sm backdrop-blur"
>
<item.icon className="h-4 w-4 shrink-0 text-primary" />
<span className="text-left">{item.text}</span>
</div>
))}
</motion.div>
</div>
</section>
{/* Features Section */}
<section className="px-4 py-20">
<div className="mx-auto max-w-6xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-12 text-center"
>
<h2 className="mb-4 text-3xl font-bold md:text-4xl"></h2>
<p className="text-lg text-muted-foreground">
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="flex"
>
<Card className="flex-1 transition-all hover:shadow-lg">
<CardHeader>
<div className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg ${feature.bgColor}`}>
<feature.icon className={`h-6 w-6 ${feature.color}`} />
</div>
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
</Card>
</motion.div>
))}
</div>
</div>
</section>
{/* Tech Stack Section */}
<section className="bg-muted/30 px-4 py-20">
<div className="mx-auto max-w-6xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-12 text-center"
>
<h2 className="mb-4 text-3xl font-bold md:text-4xl"></h2>
<p className="text-lg text-muted-foreground">
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{techStack.map((stack, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="flex"
>
<Card className="flex-1">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Layers className="h-5 w-5 text-primary" />
{stack.category}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{stack.items.map((item, i) => (
<Badge key={i} variant="secondary">
{item}
</Badge>
))}
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
</section>
{/* Quick Start Section */}
<section className="px-4 py-20">
<div className="mx-auto max-w-6xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-12 text-center"
>
<h2 className="mb-4 text-3xl font-bold md:text-4xl"></h2>
<p className="text-lg text-muted-foreground">
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{quickStartSteps.map((step, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="flex"
>
<Card className="flex-1">
<CardHeader>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-lg font-bold text-primary-foreground">
{index + 1}
</div>
<step.icon className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-lg">{step.title}</CardTitle>
<CardDescription>{step.description}</CardDescription>
</CardHeader>
<CardContent>
<code className="block rounded-md bg-muted p-3 text-sm">
{step.code}
</code>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-gradient-to-r from-primary/10 via-primary/5 to-primary/10 px-4 py-20">
<div className="mx-auto max-w-4xl text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2 className="mb-6 text-3xl font-bold md:text-4xl">
</h2>
<p className="mb-8 text-lg text-muted-foreground">
使 Hair Keeper
</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button size="lg" className="gap-2">
使
<ArrowRight className="h-5 w-5" />
</Button>
<Button size="lg" variant="outline" className="gap-2">
<Users className="h-5 w-5" />
</Button>
</div>
</motion.div>
</div>
</section>
{/* Footer */}
<footer className="border-t px-4 py-8">
<div className="mx-auto max-w-6xl text-center text-sm text-muted-foreground">
<p>© 2025 {SITE_NAME}. Web应用模板</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Settings } from 'lucide-react'
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1">
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Settings className="w-5 h-5" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-64 text-muted-foreground">
<div className="text-center">
<Settings className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-lg"></p>
<p className="text-sm"></p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,260 @@
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Edit, Trash2, MoreHorizontal } from 'lucide-react'
import { formatDate } from '@/lib/format'
import { userStatusOptions } from '@/lib/schema/user'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { User } from '@/server/routers/users'
// 操作回调类型
export type UserActions = {
onEdit: (userId: string) => void
onDelete: (userId: string) => void
}
// 列定义选项类型
export type UserColumnsOptions = {
roles?: Array<{ id: number; name: string }>
permissions?: Array<{ id: number; name: string }>
depts?: Array<{ code: string; name: string; fullName: string }>
}
// 创建用户表格列定义
export const createUserColumns = (
actions: UserActions,
options: UserColumnsOptions = {}
): ColumnDef<User>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
size: 32,
enableSorting: false,
enableHiding: false,
},
{
id: 'id',
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="用户ID" />
),
cell: ({ row }) => <div className="font-medium">{row.original.id}</div>,
enableColumnFilter: true,
meta: {
label: '用户ID',
filter: {
placeholder: '请输入用户ID',
variant: 'text',
}
},
},
{
id: 'name',
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="姓名" />
),
cell: ({ row }) => <div>{row.original.name || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '姓名',
filter: {
placeholder: '请输入姓名',
variant: 'text',
}
},
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="状态" />
),
cell: ({ row }) => {
const status = row.original.status
return (
<Badge variant={status === '在校' ? 'default' : 'secondary'}>
{status || '未知'}
</Badge>
)
},
enableColumnFilter: true,
meta: {
label: '状态',
filter: {
variant: 'select',
options: userStatusOptions,
}
},
},
{
id: 'dept',
accessorKey: 'dept',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="所属院系" />
),
cell: ({ row }) => <div>{row.original.dept?.name || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '所属院系',
filter: {
variant: 'multiSelect',
options: options.depts?.map(dept => ({
id: dept.code,
name: dept.fullName,
})) || [],
}
},
},
{
id: 'roles',
accessorKey: 'roles',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="角色" />
),
cell: ({ row }) => {
const roles = row.original.roles
return (
<div className="flex flex-wrap gap-1">
{roles.map((role) => (
<Badge key={role.id} variant="secondary">
{role.name}
</Badge>
))}
</div>
)
},
enableColumnFilter: true,
enableSorting: false,
meta: {
label: '角色',
filter: {
variant: 'select',
options: options.roles?.map(role => ({
id: role.id.toString(),
name: role.name,
})) || [],
}
},
},
{
id: 'permissions',
accessorFn: row => Array.from(
new Set(
row.roles.flatMap((role) => role.permissions.map((p) => p.name))
)
),
header: ({ column }) => (
<DataTableColumnHeader column={column} title="权限" />
),
cell: ({ getValue }) => {
return (
<div className="flex flex-wrap gap-1">
{getValue<string[]>().map((permName) => (
<Badge key={permName} variant="outline" className="text-xs">
{permName}
</Badge>
))}
</div>
)
},
enableColumnFilter: true,
enableSorting: false,
meta: {
label: '权限',
filter: {
variant: 'select',
options: options.permissions?.map(permission => ({
id: permission.id.toString(),
name: permission.name,
})) || [],
}
},
},
{
id: 'lastLoginAt',
accessorKey: 'lastLoginAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="最后登录" />
),
cell: ({ row }) => {
const lastLoginAt = row.original.lastLoginAt as Date | null
return <div>{lastLoginAt ? formatDate(lastLoginAt) : '从未登录'}</div>
},
meta: {
label: '最后登录',
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="创建时间" />
),
cell: ({ row }) => {
return <div>{formatDate(row.original.createdAt) || '-'}</div>
},
meta: {
label: '创建时间',
}
},
{
id: 'actions',
cell: ({ row }) => {
const user = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 md:w-9">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => actions.onEdit(user.id)}>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => actions.onDelete(user.id)}
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 32,
enableSorting: false,
enableHiding: false,
},
]

View File

@@ -0,0 +1,199 @@
'use client'
import React, { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc'
import { Button } from '@/components/ui/button'
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction } from '@/components/common/form-dialog'
import {
AdvancedSelect,
SelectPopover,
SelectTrigger,
SelectContent,
SelectInput,
SelectItemList,
SelectedName,
SelectedBadges
} from '@/components/common/advanced-select'
import { Users } from 'lucide-react'
import { toast } from 'sonner'
// 定义表单数据结构
const batchAuthorizationSchema = z.object({
roleId: z.number({ message: '请选择角色' }),
deptCodes: z.array(z.string()).optional(),
})
type BatchAuthorizationFormData = z.input<typeof batchAuthorizationSchema>
export const BatchAuthorizationDialog = function BatchAuthorizationDialog() {
const [isOpen, setIsOpen] = useState(false)
const [currentAction, setCurrentAction] = useState<'grant' | 'revoke' | null>(null)
const utils = trpc.useUtils()
// 获取部门列表和角色列表
const { data: depts = [] } = trpc.common.getDepts.useQuery(undefined, {
enabled: isOpen
})
const { data: roles = [] } = trpc.users.getRoles.useQuery(undefined, {
enabled: isOpen
})
// 初始化表单
const form = useForm<BatchAuthorizationFormData>({
resolver: zodResolver(batchAuthorizationSchema),
defaultValues: {
roleId: undefined,
deptCodes: [],
},
})
// 批量更新角色mutation
const batchUpdateRoleMutation = trpc.users.batchUpdateRole.useMutation({
onSuccess: (result: { count: number }, variables) => {
const action = variables.action === 'grant' ? '授予' : '撤销'
toast.success(`成功为 ${result.count} 个用户${action}角色`)
utils.users.list.invalidate()
setCurrentAction(null)
setIsOpen(false)
},
onError: (error: { message?: string }) => {
toast.error(error.message || '批量操作失败')
setCurrentAction(null)
}
})
// 处理授权
const handleGrant = async (values: BatchAuthorizationFormData) => {
setCurrentAction('grant')
batchUpdateRoleMutation.mutate({
roleId: values.roleId,
deptCodes: values.deptCodes && values.deptCodes.length > 0 ? values.deptCodes : undefined,
action: 'grant'
})
}
// 处理撤销
const handleRevoke = async (values: BatchAuthorizationFormData) => {
setCurrentAction('revoke')
batchUpdateRoleMutation.mutate({
roleId: values.roleId,
deptCodes: values.deptCodes && values.deptCodes.length > 0 ? values.deptCodes : undefined,
action: 'revoke'
})
}
// 处理对话框关闭
const handleClose = () => {
setIsOpen(false)
setCurrentAction(null)
}
// 处理对话框打开
const handleOpen = () => {
setIsOpen(true)
}
const isLoading = batchUpdateRoleMutation.isPending
// 定义表单字段配置
const fields = [
{
name: 'roleId',
label: '选择角色',
required: true,
render: ({ field }: any) => (
<div className="space-y-2">
<AdvancedSelect
options={roles.map(role => ({ ...role, id: role.id.toString() }))}
value={field.value?.toString() || ''}
onChange={(value) => field.onChange(value ? Number(value) : undefined)}
disabled={isLoading}
>
<SelectPopover>
<SelectTrigger placeholder="请选择角色" clearable>
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索角色..." />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
<p className="text-xs text-muted-foreground">
</p>
</div>
),
},
{
name: 'deptCodes',
label: '选择院系(可选)',
render: ({ field }: any) => (
<div className="space-y-2">
<AdvancedSelect
options={depts.map(dept => ({ id: dept.code, name: dept.name }))}
value={field.value || []}
onChange={(value) => field.onChange(value)}
disabled={isLoading}
multiple={{ enable: true }}
>
<SelectPopover>
<SelectTrigger placeholder="不选择则针对所有用户" clearable>
<SelectedBadges maxDisplay={3} />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索院系..." />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
<p className="text-xs text-muted-foreground">
</p>
</div>
),
},
]
return (
<>
<Button variant="outline" className="flex items-center gap-2" onClick={handleOpen}>
<Users className="h-4 w-4" />
</Button>
<FormDialog
isOpen={isOpen}
title="批量授权"
description="为指定范围的用户批量授予或撤销角色"
form={form}
fields={fields}
onClose={handleClose}
className="max-w-md"
>
<FormGridContent />
<FormActionBar>
<FormCancelAction />
<FormSubmitAction
onSubmit={handleRevoke}
variant="destructive"
isSubmitting={batchUpdateRoleMutation.isPending}
showSpinningLoader={currentAction === 'revoke'}
>
</FormSubmitAction>
<FormSubmitAction
onSubmit={handleGrant}
isSubmitting={batchUpdateRoleMutation.isPending}
showSpinningLoader={currentAction === 'grant'}
>
</FormSubmitAction>
</FormActionBar>
</FormDialog>
</>
)
}

View File

@@ -0,0 +1,397 @@
'use client'
import React, { useState } from 'react'
import { trpc } from '@/lib/trpc'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
AdvancedSelect,
SelectPopover,
SelectTrigger,
SelectContent,
SelectInput,
SelectItemList,
SelectedBadges
} from '@/components/common/advanced-select'
import { Settings, Edit, Trash2, Save, X, Plus, Check } from 'lucide-react'
import { toast } from 'sonner'
interface RoleData {
id: number
name: string
userCount: number
permissions: Array<{ id: number; name: string }>
}
interface EditingRole {
id: number | null
name: string
permissionIds: number[]
}
export function RoleManagementDialog() {
const [isOpen, setIsOpen] = useState(false)
const [editingRole, setEditingRole] = useState<EditingRole | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<number | null>(null)
const utils = trpc.useUtils()
// 获取角色列表和权限列表
const { data: roles = [], refetch: refetchRoles } = trpc.users.getRolesWithStats.useQuery(undefined, {
enabled: isOpen
})
const { data: permissions = [] } = trpc.users.getPermissions.useQuery(undefined, {
enabled: isOpen
})
// 创建角色
const createRoleMutation = trpc.users.createRole.useMutation({
onSuccess: () => {
toast.success('角色创建成功')
refetchRoles()
utils.users.getRoles.invalidate()
setIsAddingNew(false)
setEditingRole(null)
},
onError: (error) => {
toast.error(error.message || '创建角色失败')
}
})
// 更新角色
const updateRoleMutation = trpc.users.updateRole.useMutation({
onSuccess: () => {
toast.success('角色更新成功')
refetchRoles()
utils.users.getRoles.invalidate()
setEditingRole(null)
},
onError: (error) => {
toast.error(error.message || '更新角色失败')
}
})
// 删除角色
const deleteRoleMutation = trpc.users.deleteRole.useMutation({
onSuccess: () => {
toast.success('角色删除成功')
refetchRoles()
utils.users.getRoles.invalidate()
setDeleteConfirmOpen(null)
},
onError: (error) => {
toast.error(error.message || '删除角色失败')
}
})
// 开始编辑角色
const handleEditRole = (role: RoleData) => {
setEditingRole({
id: role.id,
name: role.name,
permissionIds: role.permissions.map(p => p.id)
})
setIsAddingNew(false)
}
// 开始新增角色
const handleAddNewRole = () => {
setEditingRole({
id: null,
name: '',
permissionIds: []
})
setIsAddingNew(true)
}
// 取消编辑
const handleCancelEdit = () => {
setEditingRole(null)
setIsAddingNew(false)
}
// 保存角色
const handleSaveRole = () => {
if (!editingRole) return
if (!editingRole.name.trim()) {
toast.error('角色名称不能为空')
return
}
if (editingRole.id === null) {
// 新增角色
createRoleMutation.mutate({
name: editingRole.name.trim(),
permissionIds: editingRole.permissionIds
})
} else {
// 更新角色
updateRoleMutation.mutate({
id: editingRole.id,
name: editingRole.name.trim(),
permissionIds: editingRole.permissionIds
})
}
}
// 删除角色
const handleDeleteRole = (roleId: number) => {
deleteRoleMutation.mutate({ id: roleId })
}
// 处理权限选择变化
const handlePermissionChange = (permissionIds: string | undefined | string[]) => {
if (!editingRole) return
const ids = Array.isArray(permissionIds) ? permissionIds : []
setEditingRole(prev => {
if (!prev) return prev
return { ...prev, permissionIds: ids.map(Number) }
})
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-5xl sm:max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead className="w-48"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-96"></TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.id}>
<TableCell>{role.id}</TableCell>
<TableCell>
{editingRole?.id === role.id ? (
<Input
value={editingRole.name}
onChange={(e) =>
setEditingRole(prev => prev ? { ...prev, name: e.target.value } : null)
}
placeholder="输入角色名称"
className="w-full"
/>
) : (
role.name
)}
</TableCell>
<TableCell>{role.userCount}</TableCell>
<TableCell>
{editingRole?.id === role.id ? (
<AdvancedSelect
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
value={editingRole.permissionIds.map(String)}
onChange={handlePermissionChange}
multiple={{ enable: true, limit: 1 }}
>
<SelectPopover>
<SelectTrigger placeholder="选择权限">
<SelectedBadges maxDisplay={2} />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索权限..." />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
) : (
<div className="flex flex-wrap gap-1 max-w-xs">
{role.permissions.map((perm) => (
<Badge key={perm.id} variant="outline" className="text-xs">
{perm.name}
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>
{editingRole?.id === role.id ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSaveRole}
disabled={updateRoleMutation.isPending}
className="text-green-600 hover:text-green-700 hover:bg-green-50 p-2"
>
<Save className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCancelEdit}
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-2"
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditRole(role)}
disabled={editingRole !== null || isAddingNew}
className="p-2"
>
<Edit className="h-3 w-3" />
</Button>
{role.userCount === 0 && (
<Popover
open={deleteConfirmOpen === role.id}
onOpenChange={(open) => setDeleteConfirmOpen(open ? role.id : null)}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={deleteRoleMutation.isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-2"
>
<Trash2 className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium leading-none"></h4>
<p className="text-sm text-muted-foreground">
&quot;{role.name}&quot;
</p>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmOpen(null)}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteRole(role.id)}
disabled={deleteRoleMutation.isPending}
>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
)}
</TableCell>
</TableRow>
))}
{/* 新增角色行 */}
<TableRow>
{isAddingNew && editingRole ? (
<>
<TableCell>
<Plus className="h-4 w-4 text-gray-400" />
</TableCell>
<TableCell>
<Input
value={editingRole.name}
onChange={(e) =>
setEditingRole(prev => prev ? { ...prev, name: e.target.value } : null)
}
placeholder="输入角色名称"
className="w-full"
/>
</TableCell>
<TableCell>0</TableCell>
<TableCell>
<AdvancedSelect
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
value={editingRole.permissionIds.map(String)}
onChange={handlePermissionChange}
multiple={{ enable: true }}
>
<SelectPopover>
<SelectTrigger placeholder="选择权限">
<SelectedBadges maxDisplay={2} />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索权限..." />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSaveRole}
disabled={createRoleMutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white p-2"
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCancelEdit}
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-2"
>
<X className="h-3 w-3" />
</Button>
</div>
</TableCell>
</>
) : (
<>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={handleAddNewRole}
disabled={editingRole !== null}
className="p-2 hover:bg-gray-100"
>
<Plus className="h-4 w-4 text-gray-600" />
</Button>
</TableCell>
<TableCell className="text-gray-400">+</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</>
)}
</TableRow>
</TableBody>
</Table>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,214 @@
'use client'
import React, { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { trpc } from '@/lib/trpc'
import { createUserSchema, userStatusOptions } from '@/lib/schema/user'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
import { CheckboxGroup } from '@/components/common/checkbox-group'
import {
AdvancedSelect,
SelectPopover,
SelectTrigger,
SelectContent,
SelectInput,
SelectItemList,
SelectedName
} from '@/components/common/advanced-select'
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
type CreateUserInput = z.infer<typeof createUserSchema>
const createUserDefaultValues: CreateUserInput = {
id: '',
name: '',
status: '',
deptCode: '',
password: '',
roleIds: [],
isSuperAdmin: false,
}
interface UserCreateDialogProps {
onUserCreated: () => void
}
export function UserCreateDialog({ onUserCreated }: UserCreateDialogProps) {
// 表单 dialog 控制
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
// react-hook-form 管理创建表单
const createForm = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
defaultValues: createUserDefaultValues,
})
// 获取角色列表和院系列表
const { data: roles } = trpc.users.getRoles.useQuery()
const { data: depts } = trpc.common.getDepts.useQuery()
const deptOptions = depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || []
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
options: deptOptions,
context: 'user.create.dept',
scope: 'personal',
})
// 创建用户 mutation
const createUserMutation = trpc.users.create.useMutation({
onSuccess: () => {
setIsCreateDialogOpen(false)
createForm.reset(createUserDefaultValues)
toast.success('用户创建成功')
onUserCreated()
},
onError: (error) => {
toast.error(error.message || '创建用户失败')
},
})
// 定义字段配置
const formFields: FormFieldConfig[] = React.useMemo(() => [
{
name: 'id',
label: '用户ID',
required: true,
render: ({ field }) => (
<Input {...field} placeholder="请输入用户ID职工号" />
),
},
{
name: 'name',
label: '姓名',
render: ({ field }) => (
<Input {...field} placeholder="请输入姓名" />
),
},
{
name: 'status',
label: '状态',
render: ({ field }) => (
<AdvancedSelect
{...field}
options={userStatusOptions}
>
<SelectPopover>
<SelectTrigger placeholder="请选择状态">
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'deptCode',
label: '所属院系',
render: ({ field }) => (
<AdvancedSelect
{...field}
options={sortedDeptOptions}
onChange={(value) => { logDeptSelection(value); field.onChange(value) }}
filterFunction={(option, searchValue) => {
const search = searchValue.toLowerCase()
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
(option.shortName && option.shortName.toLowerCase().includes(search))
}}
>
<SelectPopover>
<SelectTrigger placeholder="请选择院系" clearable>
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索院系名称/代码" />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'password',
label: '密码',
required: true,
render: ({ field }) => (
<Input {...field} type="password" placeholder="请输入密码至少6位" />
),
},
{
name: 'roleIds',
label: '角色',
render: ({ field }) => (
<CheckboxGroup
{...field}
options={roles || []}
idPrefix="role"
/>
),
},
{
name: 'isSuperAdmin',
label: '超级管理员',
render: ({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="isSuperAdmin"
checked={field.value || false}
onCheckedChange={field.onChange}
/>
<Label htmlFor="isSuperAdmin" className="text-sm">
</Label>
</div>
),
},
], [sortedDeptOptions, logDeptSelection, roles])
const handleSubmit = async (data: CreateUserInput) => {
createUserMutation.mutate(data)
}
const handleClose = () => {
setIsCreateDialogOpen(false)
}
return (
<>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
</Dialog>
<FormDialog
isOpen={isCreateDialogOpen}
title="创建新用户"
description="请填写用户信息"
form={createForm}
fields={formFields}
onClose={handleClose}
>
<FormGridContent />
<FormActionBar>
<FormCancelAction />
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={createUserMutation.isPending}></FormSubmitAction>
</FormActionBar>
</FormDialog>
</>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import React from 'react'
import { trpc } from '@/lib/trpc'
import { toast } from 'sonner'
import { AlertTriangle } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
interface UserDeleteDialogProps {
userId: string | null
isOpen: boolean
onClose: () => void
onUserDeleted: () => void
}
export function UserDeleteDialog({
userId,
isOpen,
onClose,
onUserDeleted,
}: UserDeleteDialogProps) {
// 获取用户信息
const { data: user } = trpc.users.getById.useQuery(
{ id: userId! },
{ enabled: !!userId }
)
// 删除用户 mutation
const deleteUserMutation = trpc.users.delete.useMutation({
onSuccess: () => {
onClose()
toast.success('用户删除成功')
onUserDeleted()
},
onError: (error) => {
toast.error(error.message || '删除用户失败')
},
})
const handleConfirmDelete = () => {
if (userId) {
deleteUserMutation.mutate({ id: userId })
}
}
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-left">
<span className="font-semibold">{user?.id}</span>{' '}
{user?.name && `(${user.name})`}
<br />
<span className="text-red-600 font-medium"></span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex gap-2 sm:gap-2">
<AlertDialogCancel disabled={deleteUserMutation.isPending}>
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 focus:ring-red-600"
onClick={handleConfirmDelete}
disabled={deleteUserMutation.isPending}
>
{deleteUserMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,229 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { trpc } from '@/lib/trpc'
import { updateUserSchema, userStatusOptions } from '@/lib/schema/user'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
import { CheckboxGroup } from '@/components/common/checkbox-group'
import {
AdvancedSelect,
SelectPopover,
SelectTrigger,
SelectContent,
SelectInput,
SelectItemList,
SelectedName
} from '@/components/common/advanced-select'
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
type UpdateUserInput = z.infer<typeof updateUserSchema>
const updateUserDefaultValues: UpdateUserInput = {
id: '',
name: '',
status: '',
deptCode: '',
password: '',
roleIds: [],
isSuperAdmin: false,
}
interface UserUpdateDialogProps {
userId: string | null
isOpen: boolean
onClose: () => void
onUserUpdated: () => void
}
export function UserUpdateDialog({ userId, isOpen, onClose, onUserUpdated }: UserUpdateDialogProps) {
// react-hook-form 管理更新表单
const updateForm = useForm<UpdateUserInput>({
resolver: zodResolver(updateUserSchema),
defaultValues: updateUserDefaultValues,
})
// 获取用户详情
const { data: user, isLoading: isLoadingUser } = trpc.users.getById.useQuery(
{ id: userId! },
{ enabled: !!userId && isOpen }
)
// 获取角色列表和院系列表
const { data: roles } = trpc.users.getRoles.useQuery()
const { data: depts } = trpc.common.getDepts.useQuery()
const deptOptions = React.useMemo(() => depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || [], [depts])
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
options: deptOptions,
context: 'user.update.dept',
scope: 'personal',
})
// 更新用户 mutation
const updateUserMutation = trpc.users.update.useMutation({
onSuccess: () => {
onClose()
toast.success('用户更新成功')
onUserUpdated()
},
onError: (error) => {
toast.error(error.message || '更新用户失败')
},
})
// 定义字段配置
const formFields: FormFieldConfig[] = React.useMemo(() => [
{
name: 'id',
label: '用户ID',
required: true,
render: ({ field }) => (
<Input {...field} placeholder="请输入用户ID职工号" disabled />
),
},
{
name: 'name',
label: '姓名',
render: ({ field }) => (
<Input {...field} placeholder="请输入姓名" />
),
},
{
name: 'status',
label: '状态',
render: ({ field }) => (
<AdvancedSelect
{...field}
options={userStatusOptions}
>
<SelectPopover>
<SelectTrigger placeholder="请选择状态">
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'deptCode',
label: '所属院系',
render: ({ field }) => (
<AdvancedSelect
{...field}
options={sortedDeptOptions}
onChange={(value) => {logDeptSelection(value); field.onChange(value)}}
filterFunction={(option, searchValue) => {
const search = searchValue.toLowerCase()
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
(option.shortName && option.shortName.toLowerCase().includes(search))
}}
>
<SelectPopover>
<SelectTrigger placeholder="请选择院系" clearable>
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索院系名称/代码" />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'password',
label: '新密码',
render: ({ field }) => (
<Input {...field} type="password" placeholder="留空则不修改密码" />
),
},
{
name: 'roleIds',
label: '角色',
render: ({ field }) => (
<CheckboxGroup
{...field}
options={roles || []}
idPrefix="role"
/>
),
},
{
name: 'isSuperAdmin',
label: '超级管理员',
render: ({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="isSuperAdmin"
checked={field.value || false}
onCheckedChange={field.onChange}
/>
<Label htmlFor="isSuperAdmin" className="text-sm">
</Label>
</div>
),
},
], [sortedDeptOptions, logDeptSelection, roles])
// 当用户数据加载完成时,重置表单
useEffect(() => {
if (user && isOpen) {
const defaultValues: UpdateUserInput = {
id: user.id,
name: user.name || '',
status: user.status || '',
deptCode: user.deptCode || '',
password: '', // 密码字段默认为空,只有填写时才更新
roleIds: user.roles?.map((role) => role.id) || [],
isSuperAdmin: user.isSuperAdmin || false,
}
updateForm.reset(defaultValues)
}
}, [user, isOpen, updateForm])
// 当对话框关闭时,清理状态
useEffect(() => {
if (!isOpen) {
updateForm.reset()
}
}, [isOpen, updateForm])
const handleSubmit = async (data: UpdateUserInput) => {
updateUserMutation.mutate(data)
}
const handleClose = () => {
onClose()
}
if (!isOpen) return null
return (
<FormDialog
isOpen={isOpen}
title="编辑用户"
description="请填写用户信息"
form={updateForm}
fields={formFields}
onClose={handleClose}
>
<FormGridContent />
<FormActionBar>
<FormCancelAction />
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={updateUserMutation.isPending}></FormSubmitAction>
</FormActionBar>
</FormDialog>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import React, { useState, useCallback, useMemo, Suspense } from 'react'
import { trpc } from '@/lib/trpc'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { UserCreateDialog } from './components/UserCreateDialog'
import { UserUpdateDialog } from './components/UserUpdateDialog'
import { UserDeleteDialog } from './components/UserDeleteDialog'
import { RoleManagementDialog } from './components/RoleManagementDialog'
import { BatchAuthorizationDialog } from './components/BatchAuthorizationDialog'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/toolbar'
import { createUserColumns, type UserColumnsOptions } from './columns'
import type { User } from '@/server/routers/users'
import { useDataTable } from '@/hooks/use-data-table'
import { keepPreviousData } from '@tanstack/react-query'
import { DataTableSortList } from '@/components/data-table/sort-list'
import { toast } from 'sonner'
import { DataTableSkeleton } from '@/components/data-table/table-skeleton'
interface UsersPageDataTableProps {
onEdit: (userId: string) => void
onDelete: (userId: string) => void
}
function UsersPageDataTable({ onEdit, onDelete }: UsersPageDataTableProps) {
// 获取角色、权限和部门列表用于过滤器选项
const { data: roles } = trpc.users.getRoles.useQuery()
const { data: permissions } = trpc.users.getPermissions.useQuery()
const { data: depts } = trpc.common.getDepts.useQuery()
// 创建表格列定义选项
const columnsOptions: UserColumnsOptions = useMemo(() => ({
roles: roles || [],
permissions: permissions || [],
depts: depts || [],
}), [roles, permissions, depts])
// 创建表格列定义
const columns = useMemo(() => createUserColumns({
onEdit,
onDelete,
}, columnsOptions), [onEdit, onDelete, columnsOptions])
// 使用 useDataTable hook传入 queryFn
const { table, queryResult } = useDataTable<User>({
columns,
initialState: {
pagination: { pageIndex: 1, pageSize: 10 },
columnPinning: { left: ["select"], right: ["actions"] },
},
getRowId: (row) => row.id,
queryFn: useCallback((params) => {
const result = trpc.users.list.useQuery(params, {
placeholderData: keepPreviousData,
})
if (result.error) {
toast.error("获取用户数据失败:" + result.error.toString().substring(0, 100))
}
return result
}, []),
})
return (
<DataTable table={table} isLoading={queryResult.isLoading}>
<DataTableToolbar table={table}>
<DataTableSortList table={table} />
</DataTableToolbar>
</DataTable>
)
}
export default function UsersPage() {
// 更新用户对话框状态
const [updateUserId, setUpdateUserId] = useState<string | null>(null)
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
// 删除用户对话框状态
const [deleteUserId, setDeleteUserId] = useState<string | null>(null)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
// 用于刷新数据的 utils
const utils = trpc.useUtils()
// 处理编辑用户
const handleEditUser = useCallback((userId: string) => {
setUpdateUserId(userId)
setIsUpdateDialogOpen(true)
}, [])
// 关闭更新对话框
const handleCloseUpdateDialog = useCallback(() => {
setIsUpdateDialogOpen(false)
setUpdateUserId(null)
}, [])
// 处理删除用户
const handleDeleteUser = useCallback((userId: string) => {
setDeleteUserId(userId)
setIsDeleteDialogOpen(true)
}, [])
// 关闭删除对话框
const handleCloseDeleteDialog = useCallback(() => {
setIsDeleteDialogOpen(false)
setDeleteUserId(null)
}, [])
// 刷新用户列表
const handleRefreshUsers = useCallback(() => {
utils.users.list.invalidate()
}, [utils])
return (
<div className="space-y-6">
{/* 用户列表和创建按钮 */}
<Card>
<CardHeader className="flex items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
<div className="flex items-center gap-2">
<RoleManagementDialog />
<BatchAuthorizationDialog />
<UserCreateDialog onUserCreated={handleRefreshUsers} />
</div>
</CardHeader>
<CardContent>
<Suspense fallback={<DataTableSkeleton columnCount={8} rowCount={10} />}>
<UsersPageDataTable onEdit={handleEditUser} onDelete={handleDeleteUser} />
</Suspense>
</CardContent>
</Card>
{/* 更新用户对话框 */}
<UserUpdateDialog
userId={updateUserId}
isOpen={isUpdateDialogOpen}
onClose={handleCloseUpdateDialog}
onUserUpdated={handleRefreshUsers}
/>
{/* 删除用户对话框 */}
<UserDeleteDialog
userId={deleteUserId}
isOpen={isDeleteDialogOpen}
onClose={handleCloseDeleteDialog}
onUserDeleted={handleRefreshUsers}
/>
</div>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Sparkles } from 'lucide-react'
interface WelcomeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* 欢迎对话框组件
* 用于在首次进入系统时显示欢迎信息
*/
export function WelcomeDialog({ open, onOpenChange }: WelcomeDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
<DialogTitle className="text-2xl"></DialogTitle>
</div>
<DialogDescription className="pt-4 text-base">
{/* 内容暂时没想好,先不实现 */}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-4">
<Button onClick={() => onOpenChange(false)}>
使
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,6 @@
import NextAuth from "next-auth"
import { authOptions } from "@/server/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@@ -0,0 +1,165 @@
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic'
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, stepCountIs, streamText, tool, LanguageModel } from 'ai'
import type { UIMessage } from 'ai'
// 创建 Anthropic 客户端
const anthropic = createAnthropic({
apiKey: process.env.PKUAI_API_KEY,
baseURL: process.env.PKUAI_API_BASE + 'api/anthropic/v1',
})
// 创建 OpenAI 客户端
const openai = createOpenAI({
apiKey: process.env.PKUAI_API_KEY,
baseURL: process.env.PKUAI_API_BASE + 'api/openai/v1',
})
/**
* 根据模型ID获取对应的LLM实例和providerOptions
*/
function getModelConfig(modelId: string): {
model: LanguageModel
providerOptions?: Record<string, any>
} {
// Claude Sonnet 4.5 标准版
if (modelId === 'claude-sonnet-4-5-20250929') {
return {
model: anthropic('claude-sonnet-4-5-20250929'),
providerOptions: {
anthropic: {
// 标准版不启用thinking
} satisfies AnthropicProviderOptions,
},
}
}
// Claude Sonnet 4.5 深度思考版
if (modelId === 'claude-sonnet-4-5-20250929:thinking') {
return {
model: anthropic('claude-sonnet-4-5-20250929'),
providerOptions: {
anthropic: {
thinking: { type: 'enabled', budgetTokens: 12000 },
} satisfies AnthropicProviderOptions,
},
}
}
// GPT-4.1
if (modelId === 'gpt-4.1') {
return {
model: openai.chat('gpt-4.1'),
providerOptions: {},
}
}
// 默认返回 Claude Sonnet 4.5
return {
model: anthropic('claude-sonnet-4-5-20250929'),
providerOptions: {
anthropic: {} satisfies AnthropicProviderOptions,
},
}
}
/**
* 根据智能体类型获取系统提示词
*/
function getSystemPrompt(agentId: string): string {
switch (agentId) {
case 'casual-chat':
return "你是一个友好的AI助手可以帮助用户解答问题如果用户询问有关当前项目的具体问题如项目结构、代码实现、项目管理等你应该明确表示无法回答这类问题建议用户切换到“项目管家”智能体获取帮助"
case 'project-assistant':
return `你是一个专业的项目管家助手,致力于帮助用户了解和管理当前项目。
你的职责:
- 帮助用户了解项目的整体结构、技术栈和实现细节
- 协助用户进行项目管理工作如Git提交、代码整理等
- 回答关于项目的各种问题
- 帮助用户理清想法,明确具体要做什么、怎么做
你的工作方式:
- 当用户表达一个想法或需求时,帮助他们分析和拆解任务
- 提供清晰的步骤和建议,让用户知道如何实现目标
- 对于项目管理任务如Git提交先了解用户的意图然后提供具体的操作建议
- 整理项目概要时,从多个维度分析项目(技术栈、架构、功能模块等)
交互原则:
- 主动询问必要的信息,确保理解用户的真实需求
- 提供具体可行的建议,而不是模糊的指导
- 对于复杂任务,分步骤说明,让用户清楚每一步的目的
- 始终站在用户的角度思考,帮助他们更高效地管理项目
请以专业、友好的态度协助用户管理项目。`
default:
return '你是一个友好的AI助手可以帮助用户解答问题。'
}
}
export async function POST(req: Request) {
try {
const { messages, agent, model, tools }: {
messages: UIMessage[]
agent?: string
model?: string
tools?: string[]
} = await req.json()
// 验证必需参数
if (!agent) {
return new Response(JSON.stringify({ error: '缺少必需参数', details: 'agent 参数是必需的' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
})
}
if (!model) {
return new Response(JSON.stringify({ error: '缺少必需参数', details: 'model 参数是必需的' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
})
}
// 根据选择的模型获取对应的LLM实例和配置
const modelConfig = getModelConfig(model)
// 根据智能体类型获取系统提示词
const systemPrompt = getSystemPrompt(agent)
// 使用 streamText 生成流式响应
const result = streamText({
model: modelConfig.model,
messages: convertToModelMessages(messages),
system: systemPrompt,
stopWhen: stepCountIs(10), // 每次工具调用都算是一个step这个参数可以让模型在调用完工具后根据结果继续生成回复
providerOptions: modelConfig.providerOptions,
// TODO: 根据 tools 参数添加工具调用功能
})
// 返回 UI 消息流响应,与 useChat 钩子完美配合
return result.toUIMessageStreamResponse({
originalMessages: messages,
// 错误处理
onError: (error) => {
console.error('聊天流错误:', error)
if (error instanceof Error) {
return error.message
}
return '处理请求时发生未知错误'
},
})
} catch (error) {
console.error('聊天API错误:', error)
return new Response(
JSON.stringify({
error: '处理请求时发生错误',
details: error instanceof Error ? error.message : '未知错误'
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
}
}

View File

@@ -0,0 +1,13 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createTRPCContext } from '@/server/trpc'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
})
export { handler as GET, handler as POST }

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

176
src/app/globals.css Normal file
View File

@@ -0,0 +1,176 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../node_modules/streamdown/dist/index.js";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.35 0.18 18);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--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);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.35 0.18 18);
--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.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.45 0.18 18);
--primary-foreground: oklch(0.985 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.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--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.45 0.18 18);
--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(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
/* 隐藏滚动条但保持滚动功能 */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* 淡色细滚动条,不占宽度或占很小宽度 */
.scrollbar-muted {
scrollbar-width: thin; /* Firefox: 使用细滚动条 */
scrollbar-color: oklch(0.708 0 0 / 0.3) transparent; /* Firefox: 滑块颜色和轨道颜色 */
}
/* Chrome, Safari and Opera */
.scrollbar-muted::-webkit-scrollbar {
width: 6px; /* 垂直滚动条宽度 */
height: 6px; /* 水平滚动条高度 */
}
.scrollbar-muted::-webkit-scrollbar-track {
background: transparent; /* 轨道透明 */
}
.scrollbar-muted::-webkit-scrollbar-thumb {
background-color: oklch(0.708 0 0 / 0.3); /* 滑块颜色淡灰色30%透明度 */
border-radius: 3px; /* 圆角滑块 */
}
.scrollbar-muted::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.708 0 0 / 0.5); /* 悬停时稍微深一点 */
}
.scrollbar-muted::-webkit-scrollbar-button {
display: none; /* 隐藏滚动条两端的端点按钮 */
}
/* 暗色模式下的滚动条 */
.dark .scrollbar-muted {
scrollbar-color: oklch(0.556 0 0 / 0.3) transparent;
}
.dark .scrollbar-muted::-webkit-scrollbar-thumb {
background-color: oklch(0.556 0 0 / 0.3);
}
.dark .scrollbar-muted::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.556 0 0 / 0.5);
}
}

51
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { TRPCProvider } from "@/components/providers/trpc-provider";
import { SessionProvider } from "@/components/providers/session-provider";
import { Toaster } from "sonner";
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { AppThemeProvider } from "@/components/providers/theme-provider";
import { SITE_NAME, SITE_DESCRIPTION } from "@/constants/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: SITE_NAME,
description: SITE_DESCRIPTION,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// next-themes推荐用suppressHydrationWarning避免服务器和客户端html标签不一致报错
return (
<html lang="zh-CN" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<AppThemeProvider>
<SessionProvider>
<TRPCProvider>
<NuqsAdapter>
{children}
</NuqsAdapter>
</TRPCProvider>
</SessionProvider>
</AppThemeProvider>
<Toaster />
</body>
</html>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
export type ActionsProps = ComponentProps<"div">;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = "ghost",
size = "sm",
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View File

@@ -0,0 +1,97 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-auto", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn("p-4", className)} {...props} />
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View File

@@ -0,0 +1,451 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { FileUIPart, UIMessage } from "ai";
import {
ChevronLeftIcon,
ChevronRightIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState, useMemo } from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[80%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
className,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
MessageResponse.displayName = "MessageResponse";
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({
data,
className,
onRemove,
...props
}: MessageAttachmentProps) {
const filename = data.filename || "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return (
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className
)}
{...props}
>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<"div">;
export function MessageAttachments({
children,
className,
...props
}: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className
)}
{...props}
>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,205 @@
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { ReactNode, ComponentProps } from "react";
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode;
};
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
);
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider:
| "moonshotai-cn"
| "lucidquery"
| "moonshotai"
| "zai-coding-plan"
| "alibaba"
| "xai"
| "vultr"
| "nvidia"
| "upstage"
| "groq"
| "github-copilot"
| "mistral"
| "vercel"
| "nebius"
| "deepseek"
| "alibaba-cn"
| "google-vertex-anthropic"
| "venice"
| "chutes"
| "cortecs"
| "github-models"
| "togetherai"
| "azure"
| "baseten"
| "huggingface"
| "opencode"
| "fastrouter"
| "google"
| "google-vertex"
| "cloudflare-workers-ai"
| "inception"
| "wandb"
| "openai"
| "zhipuai-coding-plan"
| "perplexity"
| "openrouter"
| "zenmux"
| "v0"
| "iflowcn"
| "synthetic"
| "deepinfra"
| "zhipuai"
| "submodel"
| "zai"
| "inference"
| "requesty"
| "morph"
| "lmstudio"
| "anthropic"
| "aihubmix"
| "fireworks-ai"
| "modelscope"
| "llama"
| "scaleway"
| "amazon-bedrock"
| "cerebras"
| (string & {});
};
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-3", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
width={12}
/>
);
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border",
className
)}
{...props}
/>
);
export type ModelSelectorNameProps = ComponentProps<"span">;
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { Shimmer } from "./shimmer";
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
});
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>...</Shimmer>;
}
if (duration === undefined) {
return <p></p>;
}
return <p> {duration} </p>;
};
export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown {...props}>{children}</Streamdown>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View File

@@ -0,0 +1,22 @@
"use client";
import { cn } from "@/lib/utils";
import { type ComponentProps, memo } from "react";
import { Streamdown } from "streamdown";
type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = "Response";

View File

@@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
import { motion } from "motion/react";
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react";
export type TextShimmerProps = {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
};
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);

View File

@@ -0,0 +1,215 @@
"use client"
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react'
// ==================== 类型定义 ====================
/** 选项接口 */
export interface SelectOption {
id: string
name: string
[key: string]: any // 允许额外的属性
}
/** Context 值类型定义 */
export interface AdvancedSelectContextValue {
// 状态
value: string[]
open: boolean
searchValue: string
displayCount: number
disabled: boolean
singleSelectMode: boolean
// 选项相关
options: SelectOption[]
filteredOptions: SelectOption[]
displayedOptions: SelectOption[]
selectedOptions: SelectOption[]
// 配置
limit: number
replaceOnLimit: boolean
// 操作函数
setValue: (value: string[]) => void
setOpen: (open: boolean) => void
setSearchValue: (searchValue: string) => void
setDisplayCount: (count: number | ((prev: number) => number)) => void
select: (value: string) => void
remove: (value: string) => void
clear: () => void
// 状态查询函数
isSelected: (value: string) => boolean
isLimitReached: () => boolean
}
// ==================== Context ====================
const AdvancedSelectContext = createContext<AdvancedSelectContextValue | undefined>(undefined)
export const useAdvancedSelectContext = () => {
const context = useContext(AdvancedSelectContext)
if (!context) {
throw new Error('AdvancedSelect 子组件必须在 AdvancedSelectProvider 组件内使用')
}
return context
}
// ==================== Provider 组件 ====================
export interface AdvancedSelectProviderProps {
value?: string[]
onChange?: (value: string[]) => void
options?: SelectOption[]
filterFunction?: (option: SelectOption, searchValue: string) => boolean
initialDisplayCount?: number
limit?: number // 最大选择数量0 表示无限制
replaceOnLimit?: boolean // 当达到 limit 时,是否用新值替换旧值
disabled?: boolean // 是否禁用
children: ReactNode
}
export function AdvancedSelectProvider({
value = [],
onChange,
options = [],
filterFunction,
initialDisplayCount = 99999999, // 默认无限制
limit = 0, // 默认无限制
replaceOnLimit = false, // 默认不替换
disabled = false, // 默认不禁用
children,
}: AdvancedSelectProviderProps) {
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState("")
const [displayCount, setDisplayCount] = useState(initialDisplayCount)
const singleSelectMode = limit === 1 && replaceOnLimit === true
// 默认筛选函数搜索name字段
const defaultFilterFunction = useCallback((option: SelectOption, search: string) => {
return option.name.toLowerCase().includes(search.toLowerCase())
}, [])
// 筛选选项
const filteredOptions = useMemo(() => {
if (!searchValue) return options
const filter = filterFunction || defaultFilterFunction
return options.filter(option => filter(option, searchValue))
}, [options, searchValue, filterFunction, defaultFilterFunction])
// 当前显示的选项(限制数量)
const displayedOptions = useMemo(() => {
return filteredOptions.slice(0, displayCount)
}, [filteredOptions, displayCount])
// 获取当前选中的选项列表
const selectedOptions = useMemo(() => {
const optionMap = new Map(options.map(opt => [opt.id, opt]))
return value
.map(id => optionMap.get(id))
.filter((option): option is SelectOption => option !== undefined)
}, [options, value])
// 判断是否已选中
const isSelected = useCallback((optionValue: string) => {
return value.includes(optionValue)
}, [value])
// 判断是否达到选择上限
const isLimitReached = useCallback(() => {
// limit 为 0 表示无限制
if (limit === 0) return false
return value.length >= limit
}, [value.length, limit])
// 处理选择/取消选择
const handleSelect = useCallback((selectedValue: string) => {
const option = options.find(opt => opt.id === selectedValue)
if (!option || disabled) return
const isCurrentlySelected = isSelected(option.id)
if (isCurrentlySelected) {
// 取消选择
if (!singleSelectMode) {
onChange?.(value.filter(v => v !== option.id))
}
} else {
// 添加选择
if (limit === 0) {
// 无限制
onChange?.([...value, option.id])
} else if (value.length < limit) {
// 未达到限制
onChange?.([...value, option.id])
} else if (replaceOnLimit) {
// 达到限制且启用替换:移除第一个,添加新的
onChange?.([...value.slice(1), option.id])
}
// 达到限制且不替换:不做任何操作
}
// 单选模式limit=1时自动关闭
if (singleSelectMode) {
setOpen(false)
}
}, [options, disabled, isSelected, singleSelectMode, onChange, value, limit, replaceOnLimit])
// 处理移除单个选项
const handleRemove = useCallback((optionValue: string) => {
if (!disabled) {
onChange?.(value.filter(v => v !== optionValue))
}
}, [disabled, onChange, value])
// 处理清空所有
const handleClear = useCallback(() => {
if (!disabled) {
onChange?.([])
}
}, [disabled, onChange])
// 重置搜索时重置显示数量
useEffect(() => {
setDisplayCount(initialDisplayCount)
}, [searchValue, initialDisplayCount])
const contextValue: AdvancedSelectContextValue = {
// 状态
value,
open,
searchValue,
displayCount,
singleSelectMode,
// 选项相关
options,
filteredOptions,
displayedOptions,
selectedOptions,
// 配置
limit,
replaceOnLimit,
disabled,
// 操作函数
setValue: onChange || (() => {}),
setOpen,
setSearchValue,
setDisplayCount,
select: handleSelect,
remove: handleRemove,
clear: handleClear,
isSelected,
isLimitReached
}
return (
<AdvancedSelectContext.Provider value={contextValue}>
{children}
</AdvancedSelectContext.Provider>
)
}

View File

@@ -0,0 +1,399 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Badge } from "@/components/ui/badge"
import {
useAdvancedSelectContext,
AdvancedSelectProvider,
type SelectOption,
} from "./advanced-select-provider"
// ==================== Popover 组件 ====================
export interface SelectPopoverProps {
children?: React.ReactNode
}
export function SelectPopover({
children,
}: SelectPopoverProps) {
const context = useAdvancedSelectContext()
return (
<Popover open={context.open} onOpenChange={context.setOpen}>
{children}
</Popover>
)
}
// ==================== 选项列表触发器组件 ====================
export interface SelectTriggerProps {
placeholder?: string
className?: string
clearable?: boolean
onClear?: () => void
children?: React.ReactNode
}
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
({ placeholder = "请选择", className, clearable = false, onClear, children }, ref) => {
const context = useAdvancedSelectContext()
const handleClear = React.useCallback((e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation()
onClear?.()
context.clear()
}, [onClear, context])
const hasValue = context.value.length > 0
return (
<PopoverTrigger asChild>
<Button
ref={ref}
type="button"
variant="outline"
role="combobox"
aria-expanded={context.open}
className={cn("w-full justify-between", className)}
disabled={context.disabled}
onClick={() => context.setOpen(!context.open)}
>
<span className={cn(!hasValue && "opacity-60", "truncate")}>
{hasValue ? children : placeholder}
</span>
<div className="ml-2 flex items-center gap-1 shrink-0">
<ChevronsUpDownIcon className="h-4 w-4 opacity-50" />
{clearable && hasValue && (
<span
role="button"
tabIndex={0}
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClear(e)
}
}}
className="h-4 w-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
aria-label="清空"
>
<X className="h-4 w-4" />
</span>
)}
</div>
</Button>
</PopoverTrigger>
)
}
)
SelectTrigger.displayName = "SelectTrigger"
// ==================== 选项列表展示容器组件 ====================
export interface SelectContentProps {
className?: string
align?: "start" | "center" | "end"
children: React.ReactNode
}
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
({ className, align = "start", children }, ref) => {
return (
<PopoverContent
ref={ref}
className={cn("p-0 z-[60] min-w-[12.5rem] max-w-[min(30rem,80vw)]", className)}
align={align}
onWheel={(e) => {
// 确保滚动行为独立,不影响背后的页面。
e.stopPropagation()
}}
>
<Command shouldFilter={false}>
{children}
</Command>
</PopoverContent>
)
}
)
SelectContent.displayName = "SelectContent"
// ==================== 选项列表过滤器类型组件 ====================
export interface SelectInputProps {
placeholder?: string
}
export function SelectInput({
placeholder = "搜索...",
}: SelectInputProps) {
const context = useAdvancedSelectContext()
return (
<CommandInput
placeholder={placeholder}
value={context.searchValue}
onValueChange={context.setSearchValue}
/>
)
}
// ==================== 选项列表展示类组件 ====================
export interface SelectItemListProps {
emptyText?: React.ReactNode
className?: string
stepDisplayCount?: number
children?: (option: SelectOption) => React.ReactNode
}
export function SelectItemList({
emptyText = "未找到相关选项",
className,
stepDisplayCount = 20,
children
}: SelectItemListProps) {
const context = useAdvancedSelectContext()
const handleScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget
const { scrollTop, scrollHeight, clientHeight } = target
// 当滚动到底部附近时加载更多距离底部50px时触发
if (scrollHeight - scrollTop - clientHeight < 50 && context.displayedOptions.length < context.filteredOptions.length) {
context.setDisplayCount((prev: number) => Math.min(prev + stepDisplayCount, context.filteredOptions.length))
}
}, [stepDisplayCount, context])
if (context.filteredOptions.length === 0) {
return (
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
</CommandList>
)
}
return (
<CommandList
className={cn("max-h-[200px] overflow-auto", className)}
onScroll={handleScroll}
onWheel={(e) => {
e.stopPropagation()
}}
>
<CommandGroup>
{context.displayedOptions.map((option) => {
const isSelected = context.isSelected(option.id)
const shouldDisable = context.disabled || (context.isLimitReached() && !context.replaceOnLimit && !isSelected)
return (
<CommandItem
key={option.id}
value={option.id}
onSelect={context.select}
disabled={shouldDisable}
>
{children ? children(option) : option.name}
<CheckIcon
className={cn(
"ml-auto",
isSelected ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
)
})}
{context.displayedOptions.length < context.filteredOptions.length && (
<div className="px-2 py-1 text-xs text-muted-foreground text-center">
...
</div>
)}
</CommandGroup>
</CommandList>
)
}
// ==================== Badge 选项展示组件 ====================
export interface SelectedBadgesProps {
maxDisplay?: number
onRemove?: (id: string) => void
className?: string
}
export function SelectedBadges({
maxDisplay = 3,
onRemove,
className,
}: SelectedBadgesProps) {
const context = useAdvancedSelectContext()
const displayedOptions = context.selectedOptions.slice(0, maxDisplay)
const remainingCount = context.selectedOptions.length - maxDisplay
const handleRemove = React.useCallback((optionId: string, e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation()
context.remove(optionId)
onRemove?.(optionId)
}, [context, onRemove])
return (
<div className={cn("flex flex-wrap gap-1", className)}>
{displayedOptions.map((option) => (
<Badge
key={option.id}
variant="secondary"
size="sm"
className="gap-1"
>
<span className="truncate max-w-[120px]">{option.name}</span>
<span
role="button"
tabIndex={0}
onClick={(e) => handleRemove(option.id, e)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleRemove(option.id, e)
}
}}
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100 focus:outline-none cursor-pointer"
>
<X className="h-3 w-3" />
</span>
</Badge>
))}
{remainingCount > 0 && (
<Badge variant="secondary" size="sm">
+{remainingCount}
</Badge>
)}
</div>
)
}
// ==================== 字符串拼接选项展示组件 ====================
export interface SelectedNameProps {
separator?: string
maxLength?: number
className?: string
}
export function SelectedName({
separator = ", ",
maxLength,
className,
}: SelectedNameProps) {
const context = useAdvancedSelectContext()
const names = context.selectedOptions.map(option => option.name).join(separator)
const displayText = maxLength && names.length > maxLength
? `${names.slice(0, maxLength)}...`
: names
return (
<span className={cn("truncate", className)}>
{displayText}
</span>
)
}
// ==================== 组合式 API 组件,封装 Provider提供简单易用的API ====================
export interface AdvancedSelectProps {
value?: string | number | string[] | number[] | null
onChange?: (value: any) => void
options?: SelectOption[]
disabled?: boolean
multiple?: {
enable?: boolean // 多选模式,默认为单选
limit?: number // 多选模式下限制选择上限0表示不显示
replaceOnLimit?: boolean // 多选模式下,选择达到上限时,新的选项会替换掉最开始选择的
}
filterFunction?: (option: SelectOption, searchValue: string) => boolean
initialDisplayCount?: number, // 初始显示的选项数量默认最多显示50个
children?: React.ReactNode
}
export function AdvancedSelect({
value,
onChange,
options = [],
disabled = false,
multiple = {},
filterFunction,
initialDisplayCount = 50,
children,
}: AdvancedSelectProps) {
const { limit, replaceOnLimit } = multiple.enable ?
{ limit: multiple.limit || 0, replaceOnLimit: !!multiple.replaceOnLimit } :
{ limit: 1, replaceOnLimit: true }
const singleSelectMode = !multiple.enable
// 标准化 value 为字符串数组格式context 使用context内部统一为字符串数组
const normalizedValue = React.useMemo(() => {
if (Array.isArray(value)) {
return value.map(v => String(v))
}
return value !== undefined && value !== null && value !== "" ? [String(value)] : []
}, [value])
// 标准化 onChange 为适配原始类型
const normalizedOnChange = React.useCallback((newValue: string[]) => {
if (!onChange) return
if (singleSelectMode) {
// 单选模式:返回单个值或 undefined
if (newValue.length === 0) {
onChange(null) // react-hook-form中null表示清空undefined表示重置
return
}
// 根据原始 value 的类型返回对应类型
if (typeof value === 'number') {
onChange(Number(newValue[0]))
} else {
onChange(newValue[0])
}
} else {
// 多选模式:返回数组
if (newValue.length === 0) {
onChange(null)
return
}
// 根据原始 value 的类型返回对应类型的数组
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'number') {
onChange(newValue.map(v => Number(v)))
} else {
onChange(newValue)
}
}
}, [onChange, singleSelectMode, value])
return (
<AdvancedSelectProvider
value={normalizedValue}
onChange={normalizedOnChange}
options={options}
filterFunction={filterFunction}
initialDisplayCount={initialDisplayCount}
limit={limit}
replaceOnLimit={replaceOnLimit}
disabled={disabled}
>
{children}
</AdvancedSelectProvider>
)
}
// 导出类型
export type { SelectOption }

View File

@@ -0,0 +1,271 @@
"use client"
import * as React from "react"
import { CheckCircle2, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
// 卡片选项接口
export interface CardSelectOption {
id: string | number
name: string
description?: string
url?: string
websiteUrl?: string
type?: string
[key: string]: any // 允许额外的属性
}
// 卡片选项项组件属性
export interface CardSelectItemProps {
option: CardSelectOption
selected?: boolean
onSelect?: (id: string | number) => void
showCheckbox?: boolean
showExternalLink?: boolean
showBadge?: boolean
renderExtra?: (option: CardSelectOption) => React.ReactNode
renderActions?: (option: CardSelectOption) => React.ReactNode
className?: string
disabled?: boolean
}
/**
* 卡片选项项组件
* 用于展示单个卡片选项,支持复选框、外部链接、徽章等
*/
export function CardSelectItem({
option,
selected = false,
onSelect,
showCheckbox = false,
showExternalLink = false,
showBadge = false,
renderExtra,
renderActions,
className,
disabled = false
}: CardSelectItemProps) {
const handleClick = React.useCallback(() => {
if (!disabled) {
onSelect?.(option.id)
}
}, [onSelect, option.id, disabled])
const handleCheckboxChange = React.useCallback(() => {
if (!disabled) {
onSelect?.(option.id)
}
}, [onSelect, option.id, disabled])
return (
<div
className={cn(
"flex items-start space-x-3 p-3 rounded-lg border bg-background transition-all",
showCheckbox && "cursor-pointer hover:shadow-sm hover:border-primary/50",
selected && "border-primary bg-primary/5 shadow-sm",
disabled && "opacity-50 cursor-not-allowed",
className
)}
onClick={showCheckbox ? handleClick : undefined}
>
{showCheckbox && (
<Checkbox
id={`card-select-${option.id}`}
checked={selected}
onCheckedChange={handleCheckboxChange}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
disabled={disabled}
/>
)}
<div className="flex-1 space-y-1.5 min-w-0">
<div className="flex items-center gap-2">
<Label
htmlFor={showCheckbox ? `card-select-${option.id}` : undefined}
className={cn(
"text-sm font-medium",
showCheckbox && "cursor-pointer"
)}
onClick={(e) => showCheckbox && e.stopPropagation()}
>
{option.name}
</Label>
{selected && showCheckbox && (
<CheckCircle2 className="h-3.5 w-3.5 text-primary flex-shrink-0" />
)}
{showBadge && option.type && (
<Badge variant="secondary" className="text-xs">
{option.type}
</Badge>
)}
{showExternalLink && option.websiteUrl && (
<a
href={option.websiteUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-primary transition-colors ml-auto"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
{option.description && (
<p className="text-xs text-muted-foreground break-all line-clamp-3">
{option.description}
</p>
)}
{option.url && !option.description && (
<p className="text-xs text-muted-foreground break-all line-clamp-2">
{option.url}
</p>
)}
{renderExtra?.(option)}
</div>
{renderActions && (
<div className="shrink-0">
{renderActions(option)}
</div>
)}
</div>
)
}
export interface CardSelectProps {
value?: (string | number)[]
onChange?: (value: (string | number)[]) => void
options?: CardSelectOption[]
className?: string
containerClassName?: string
disabled?: boolean
multiple?: boolean
showCheckbox?: boolean
showExternalLink?: boolean
showBadge?: boolean
renderExtra?: (option: CardSelectOption) => React.ReactNode
renderActions?: (option: CardSelectOption) => React.ReactNode
maxHeight?: string
// 分页相关属性
enablePagination?: boolean
pageSize?: number
showPaginationInfo?: boolean
}
/**
* 卡片选择组件
* 支持单选和多选模式,支持分页展示
*/
export function CardSelect({
value = [],
onChange,
options = [],
className,
containerClassName = "space-y-2 max-h-64 overflow-y-auto rounded-lg border bg-muted/30 p-3",
disabled = false,
multiple = true,
showCheckbox = true,
showExternalLink = false,
showBadge = false,
renderExtra,
renderActions,
maxHeight,
enablePagination = false,
pageSize = 3,
showPaginationInfo = true
}: CardSelectProps) {
const [currentPage, setCurrentPage] = React.useState(1)
const handleSelect = React.useCallback((id: string | number) => {
if (disabled) return
if (multiple) {
const newValue = value.includes(id)
? value.filter(v => v !== id)
: [...value, id]
onChange?.(newValue)
} else {
onChange?.([id])
}
}, [value, onChange, disabled, multiple])
// 计算分页数据
const totalPages = enablePagination ? Math.ceil(options.length / pageSize) : 1
const startIndex = enablePagination ? (currentPage - 1) * pageSize : 0
const endIndex = enablePagination ? startIndex + pageSize : options.length
const displayOptions = enablePagination ? options.slice(startIndex, endIndex) : options
// 重置页码当选项变化时
React.useEffect(() => {
if (enablePagination && currentPage > totalPages && totalPages > 0) {
setCurrentPage(1)
}
}, [options.length, enablePagination, currentPage, totalPages])
const handlePrevPage = React.useCallback(() => {
setCurrentPage(prev => Math.max(1, prev - 1))
}, [])
const handleNextPage = React.useCallback(() => {
setCurrentPage(prev => Math.min(totalPages, prev + 1))
}, [totalPages])
const containerStyle = maxHeight ? { maxHeight } : undefined
return (
<div className="space-y-3 overflow-auto">
<div className={cn(containerClassName, className)} style={containerStyle}>
{displayOptions.map((option) => (
<CardSelectItem
key={option.id}
option={option}
selected={value.includes(option.id)}
onSelect={handleSelect}
showCheckbox={showCheckbox}
showExternalLink={showExternalLink}
showBadge={showBadge}
renderExtra={renderExtra}
renderActions={renderActions}
disabled={disabled}
/>
))}
</div>
{enablePagination && totalPages > 1 && (
<div className="flex items-center justify-between px-2">
{showPaginationInfo && (
<div className="text-xs text-muted-foreground">
{startIndex + 1}-{Math.min(endIndex, options.length)} {options.length}
</div>
)}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-xs text-muted-foreground min-w-[60px] text-center">
{currentPage} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import React from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
export interface CheckboxOption {
id: number | string
name: string
[key: string]: any // 允许额外的属性
}
export interface CheckboxGroupProps {
options: CheckboxOption[]
value?: (number | string)[]
onChange?: (value: (number | string)[]) => void
className?: string
itemClassName?: string
labelClassName?: string
idPrefix?: string
disabled?: boolean
}
export function CheckboxGroup({
options,
value = [],
onChange,
className = "space-y-2",
itemClassName = "flex items-center space-x-2",
labelClassName = "text-sm",
idPrefix = "checkbox",
disabled = false,
}: CheckboxGroupProps) {
const handleToggle = (optionId: number | string, checked: boolean) => {
const newValue = checked
? [...value, optionId]
: value.filter((id) => id !== optionId)
onChange?.(newValue)
}
if (!options || options.length === 0) {
return null
}
return (
<div className={className}>
{options.map((option) => (
<div key={option.id} className={itemClassName}>
<Checkbox
id={`${idPrefix}-${option.id}`}
checked={value.includes(option.id)}
onCheckedChange={(checked) =>
handleToggle(option.id, checked as boolean)
}
disabled={disabled}
/>
<Label
htmlFor={`${idPrefix}-${option.id}`}
className={labelClassName}
>
{option.name}
</Label>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import * as React from "react"
import { format, parse, isValid } from "date-fns"
import { Calendar as CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export interface DatePickerProps {
value?: Date
onChange?: (date: Date | undefined) => void
placeholder?: string
disabled?: boolean
className?: string
buttonClassName?: string
inputClassName?: string
popoverClassName?: string
calendarClassName?: string
formatString?: string
inputFormat?: string
}
export function DatePicker({
value,
onChange,
placeholder = "选择日期",
disabled = false,
className,
buttonClassName,
inputClassName,
popoverClassName,
calendarClassName,
inputFormat = "yyyy-MM-dd"
}: DatePickerProps) {
const [inputValue, setInputValue] = React.useState("")
const [isOpen, setIsOpen] = React.useState(false)
// 同步外部value到input
React.useEffect(() => {
if (value) {
setInputValue(format(value, inputFormat))
} else {
setInputValue("")
}
}, [value, inputFormat])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setInputValue(newValue)
// 尝试解析输入的日期
if (newValue) {
const parsedDate = parse(newValue, inputFormat, new Date())
if (isValid(parsedDate)) {
onChange?.(parsedDate)
}
} else {
onChange?.(undefined)
}
}
const handleInputBlur = () => {
// 输入框失焦时如果日期无效重置为当前value的格式
if (inputValue && value) {
const parsedDate = parse(inputValue, inputFormat, new Date())
if (!isValid(parsedDate)) {
setInputValue(format(value, inputFormat))
}
}
}
const handleCalendarSelect = (date: Date | undefined) => {
onChange?.(date)
setIsOpen(false)
}
return (
<div className={cn("flex items-center gap-2", className)}>
<Input
type="text"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
placeholder={placeholder}
disabled={disabled}
className={cn("flex-1", inputClassName)}
/>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
disabled={disabled}
className={cn("shrink-0", buttonClassName)}
>
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className={cn("w-auto p-0", popoverClassName)}
align="end"
>
<Calendar
mode="single"
selected={value}
onSelect={handleCalendarSelect}
disabled={disabled}
className={calendarClassName}
/>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import React from 'react'
import { format } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { Calendar as CalendarIcon, X } from 'lucide-react'
import { DateRange } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
// 日期范围类型
export interface DateRangeValue {
from?: Date
to?: Date
}
export interface DateRangePickerProps {
value?: DateRangeValue
onChange?: (value: DateRangeValue | undefined) => void
placeholder?: string
className?: string
disabled?: boolean
numberOfMonths?: number
clearable?: boolean
}
export function DateRangePicker({
value,
onChange,
placeholder = '选择日期范围',
className,
disabled = false,
numberOfMonths = 2,
clearable = false
}: DateRangePickerProps) {
const [open, setOpen] = React.useState(false)
const [isHovered, setIsHovered] = React.useState(false)
const handleSelect = React.useCallback((range: DateRange | undefined) => {
if (range) {
onChange?.({
from: range.from,
to: range.to
})
} else {
onChange?.(undefined)
}
}, [onChange])
const handleClear = React.useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onChange?.(undefined)
}, [onChange])
const formatDateRange = React.useCallback((dateRange?: DateRangeValue) => {
if (!dateRange?.from) {
return placeholder
}
if (dateRange.to) {
return `${format(dateRange.from, 'yyyy-MM-dd', { locale: zhCN })} 至 ${format(dateRange.to, 'yyyy-MM-dd', { locale: zhCN })}`
}
return format(dateRange.from, 'yyyy-MM-dd', { locale: zhCN })
}, [placeholder])
return (
<div className={cn('grid gap-2', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
className="relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Button
id="date"
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!value?.from && 'text-muted-foreground',
clearable && value?.from && 'pr-8'
)}
disabled={disabled}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formatDateRange(value)}
</Button>
{clearable && value?.from && isHovered && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
disabled={disabled}
>
<X className="h-4 w-4" />
<span className="sr-only"></span>
</button>
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
autoFocus
mode="range"
defaultMonth={value?.from}
selected={value ? { from: value.from, to: value.to } : undefined}
onSelect={handleSelect}
numberOfMonths={numberOfMonths}
locale={zhCN}
/>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,968 @@
'use client';
import { useCallback, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import useEmblaCarousel from 'embla-carousel-react';
import { useGesture } from '@use-gesture/react';
import { motion, useMotionValue, useSpring, animate, useMotionValueEvent } from 'framer-motion';
import {
X,
FileText,
FileArchive,
FileSpreadsheet,
Image as ImageIcon,
Video,
Music,
File,
Download,
ZoomIn,
ZoomOut,
RotateCw,
ChevronLeft,
ChevronRight,
Maximize2,
HelpCircle,
ArrowLeft,
ArrowRight,
Plus,
Minus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Kbd } from '@/components/ui/kbd';
import { cn, downloadFromFile } from '@/lib/utils';
import { formatBytes } from '@/lib/format';
// ==================== 工具函数 ====================
/**
* 根据文件 MIME 类型返回对应的图标
*/
export const getFileIcon = (type: string | undefined) => {
if (!!type) {
if (type.startsWith('image/')) return <ImageIcon className="size-6" />;
if (type.startsWith('video/')) return <Video className="size-6" />;
if (type.startsWith('audio/')) return <Music className="size-6" />;
if (type.includes('pdf')) return <FileText className="size-6" />;
if (type.includes('word') || type.includes('doc')) return <FileText className="size-6" />;
if (type.includes('excel') || type.includes('sheet')) return <FileSpreadsheet className="size-6" />;
if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return <FileArchive className="size-6" />;
}
return <File className="size-6" />;
};
// ==================== 类型定义 ====================
/**
* 文件预览项的基础接口
*/
export interface FilePreviewItem {
/** 文件唯一标识 */
id: string;
/** 文件名称 */
name: string;
/** 文件大小(字节) */
size: number;
/** 文件 MIME 类型 */
type?: string;
/** 预览 URL用于图片预览 */
preview?: string;
/** 上传进度0-100undefined 表示未上传或已完成 */
progress?: number;
/** 浏览器 File 对象(可选,用于下载功能) */
file?: File;
}
/**
* 文件卡片预览组件的属性
*/
export interface FileCardPreviewProps {
/** 文件列表 */
files: FilePreviewItem[];
/** 外层容器的自定义类名 */
className?: string;
/** 网格容器的自定义类名 */
gridClassName?: string;
/** 是否禁用操作按钮 */
disabled?: boolean;
/** 是否显示下载按钮 */
showDownload?: boolean;
/** 是否显示删除按钮 */
showRemove?: boolean;
/** 是否显示文件信息(文件名和大小) */
showFileInfo?: boolean;
/** 删除文件的回调函数 */
onRemove?: (id: string, file: FilePreviewItem) => void;
/** 下载文件的回调函数(如果不提供,将使用默认的 downloadFromFile */
onDownload?: (id: string, file: FilePreviewItem) => void;
/** 点击文件卡片的回调函数 */
onClick?: (id: string, file: FilePreviewItem) => void;
}
// ==================== 文件卡片预览组件 ====================
/**
* 文件卡片预览组件
*
* 用于以卡片网格形式展示文件列表,支持图片预览、文件信息显示、下载和删除操作。
*
* 特性:
* - 响应式网格布局(移动端 1 列,平板 2 列,桌面 3 列)
* - 图片文件显示预览图,其他文件显示对应图标
* - 支持上传进度显示(圆形进度条)
* - PC 端悬停显示操作按钮和文件信息
* - 移动端点击激活显示操作按钮和文件信息
* - 可自定义是否显示下载、删除按钮和文件信息
*
* @example
* ```tsx
* <FileCardPreview
* files={files}
* showDownload
* showRemove
* onRemove={(id) => console.log('Remove', id)}
* />
* ```
*/
export function FileCardPreview({
files,
className,
gridClassName,
disabled = false,
showDownload = true,
showRemove = true,
showFileInfo = true,
onRemove,
onDownload,
onClick,
}: FileCardPreviewProps) {
const [activeFileId, setActiveFileId] = useState<string | null>(null);
const [carouselOpen, setCarouselOpen] = useState(false);
const [carouselIndex, setCarouselIndex] = useState(0);
const handleRemove = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileItem = files.find((f) => f.id === id);
if (fileItem && onRemove) {
onRemove(id, fileItem);
}
setActiveFileId(null);
},
[files, onRemove]
);
const handleDownload = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileItem = files.find((f) => f.id === id);
if (fileItem) {
if (onDownload) {
onDownload(id, fileItem);
} else if (fileItem.file) {
downloadFromFile(fileItem.file);
}
}
},
[files, onDownload]
);
const handlePreview = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
const index = files.findIndex((f) => f.id === id);
if (index !== -1) {
setCarouselIndex(index);
setCarouselOpen(true);
}
},
[files]
);
const handleFileClick = useCallback(
(id: string) => {
const fileItem = files.find((f) => f.id === id);
if (fileItem && onClick) {
onClick(id, fileItem);
}
setActiveFileId((prev) => (prev === id ? null : id));
},
[files, onClick]
);
if (files.length === 0) {
return null;
}
return (
<div className={cn('w-full', className)}>
<div
className={cn(
'grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3',
gridClassName
)}
>
{files.map((fileItem) => (
<div
key={fileItem.id}
className="group relative aspect-square cursor-pointer p-1"
onClick={() => handleFileClick(fileItem.id)}
>
<div
className={cn(
'relative h-full overflow-hidden rounded-lg border bg-card transition-all',
activeFileId === fileItem.id
? 'border-primary ring-2 ring-primary ring-offset-2 shadow-lg'
: 'md:group-hover:border-primary/50 md:group-hover:shadow-md'
)}
>
{/* 文件预览区域 */}
{fileItem.type?.startsWith('image/') && fileItem.preview ? (
<img
src={fileItem.preview}
alt={fileItem.name}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground">
{getFileIcon(fileItem.type)}
</div>
)}
{/* 上传进度指示器 */}
{fileItem.progress !== undefined && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="relative size-16">
<svg className="size-full -rotate-90" viewBox="0 0 100 100">
{/* 背景圆环 */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
strokeWidth="8"
className="text-white/20"
/>
{/* 进度圆环 */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 45}`}
strokeDashoffset={`${2 * Math.PI * 45 * (1 - fileItem.progress / 100)}`}
className="text-primary transition-all duration-300"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-semibold text-white">
{Math.round(fileItem.progress)}%
</span>
</div>
</div>
</div>
)}
{/* PC端悬停或移动端点击后显示的操作按钮 */}
{(showDownload || showRemove || fileItem.type?.startsWith('image/')) && (
<div
className={cn(
'absolute inset-0 flex items-center justify-center gap-2 rounded-lg bg-black/50 transition-opacity',
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100 pointer-events-none md:group-hover:pointer-events-auto'
)}
>
{fileItem.type?.startsWith('image/') && (
<Button
onClick={(e) => handlePreview(fileItem.id, e)}
type="button"
variant="secondary"
size="icon"
disabled={disabled}
className={cn(
'size-7',
activeFileId === fileItem.id && 'pointer-events-auto'
)}
title="预览"
>
<ZoomIn className="size-4" />
</Button>
)}
{showDownload && (fileItem.file || onDownload) && (
<Button
onClick={(e) => handleDownload(fileItem.id, e)}
type="button"
variant="secondary"
size="icon"
disabled={disabled}
className={cn(
'size-7',
activeFileId === fileItem.id && 'pointer-events-auto'
)}
title="下载"
>
<Download className="size-4" />
</Button>
)}
{showRemove && onRemove && (
<Button
onClick={(e) => handleRemove(fileItem.id, e)}
type="button"
variant="secondary"
size="icon"
disabled={disabled}
className={cn(
'size-7',
activeFileId === fileItem.id && 'pointer-events-auto'
)}
title="删除"
>
<X className="size-4" />
</Button>
)}
</div>
)}
{/* PC端悬停或移动端点击后显示的文件信息 */}
{showFileInfo && (
<div
className={cn(
'absolute bottom-0 left-0 right-0 rounded-b-lg bg-gradient-to-t from-black/80 via-black/60 to-transparent p-2 pt-8 text-white transition-opacity',
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100'
)}
>
<p className="truncate text-xs font-medium" title={fileItem.name}>
{fileItem.name}
</p>
<p className="text-xs text-gray-300">
{formatBytes(fileItem.size)}
</p>
</div>
)}
</div>
</div>
))}
</div>
{/* 轮播预览 */}
<FileCarouselPreview
files={files}
initialIndex={carouselIndex}
open={carouselOpen}
onClose={() => setCarouselOpen(false)}
onDownload={onDownload}
/>
</div>
);
}
// ==================== 轮播预览组件 ====================
/**
* 文件轮播预览组件的属性
*/
export interface FileCarouselPreviewProps {
/** 文件列表 */
files: FilePreviewItem[];
/** 初始显示的文件索引 */
initialIndex?: number;
/** 是否打开预览 */
open: boolean;
/** 关闭预览的回调 */
onClose: () => void;
/** 下载文件的回调函数 */
onDownload?: (id: string, file: FilePreviewItem) => void;
}
/**
* 文件轮播预览组件
*
* 全屏图片查看器,支持缩放、旋转、拖拽、键盘导航等功能。
*
* 特性:
* - Portal 渲染(避免 z-index 问题)
* - ESC 键关闭,缩放、旋转、拖拽均有快捷键支持
* - 缩放(按钮 + 滚轮)、旋转、拖拽移动(放大后)
* - 下载图片
* - 平滑动画
* - 响应式设计
* - 触摸友好
* - ARIA 属性
* - 键盘导航
*
* @example
* ```tsx
* const [open, setOpen] = useState(false);
* const [index, setIndex] = useState(0);
*
* <FileCarouselPreview
* files={files}
* initialIndex={index}
* open={open}
* onClose={() => setOpen(false)}
* />
* ```
*/
export function FileCarouselPreview({
files,
initialIndex = 0,
open,
onClose,
onDownload,
}: FileCarouselPreviewProps) {
const [mounted, setMounted] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({
startIndex: initialIndex,
loop: false,
duration: 20, // 设置切换动画时长(毫秒)
});
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [rotation, setRotation] = useState(0);
const [showHelp, setShowHelp] = useState(false);
const [currentScale, setCurrentScale] = useState(1);
// 使用 framer-motion 的 motion values 和 spring 动画
const scaleMotion = useMotionValue(1);
const xMotion = useMotionValue(0);
const yMotion = useMotionValue(0);
// 使用 useSpring 包装 motion values添加弹性物理效果
const scale = useSpring(scaleMotion, { stiffness: 300, damping: 30 });
const x = useSpring(xMotion, { stiffness: 300, damping: 30 });
const y = useSpring(yMotion, { stiffness: 300, damping: 30 });
// 监听 scale 变化,更新 state 以触发按钮状态更新
useMotionValueEvent(scale, "change", (latest) => {
setCurrentScale(latest);
});
const imageRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const thumbnailRef = useRef<HTMLDivElement>(null);
// 确保组件在客户端挂载
useEffect(() => {
setMounted(true);
}, []);
// 监听 embla 选中事件
useEffect(() => {
if (!emblaApi) return;
const onSelect = () => {
setCurrentIndex(emblaApi.selectedScrollSnap());
// 切换图片时重置变换
animate(scaleMotion, 1, { duration: 0.2 });
animate(xMotion, 0, { duration: 0.2 });
animate(yMotion, 0, { duration: 0.2 });
setRotation(0);
};
emblaApi.on('select', onSelect);
onSelect();
return () => {
emblaApi.off('select', onSelect);
};
}, [emblaApi, scaleMotion, xMotion, yMotion]);
// 禁用背景滚动
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
}, [open]);
// 缩放功能
const handleZoomIn = useCallback(() => {
const current = scaleMotion.get();
animate(scaleMotion, Math.min(current + 0.25, 5), { duration: 0.2 });
}, [scaleMotion]);
const handleZoomOut = useCallback(() => {
const current = scaleMotion.get();
animate(scaleMotion, Math.max(current - 0.25, 0.5), { duration: 0.2 });
}, [scaleMotion]);
const handleRotate = useCallback(() => {
setRotation((prev) => (prev + 90) % 360);
}, []);
const handleReset = useCallback(() => {
animate(scaleMotion, 1, { duration: 0.2 });
animate(xMotion, 0, { duration: 0.2 });
animate(yMotion, 0, { duration: 0.2 });
setRotation(0);
}, [scaleMotion, xMotion, yMotion]);
// 键盘事件处理
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
// 阻止事件冒泡,避免关闭父对话框
switch (e.key) {
case 'Escape':
e.preventDefault();
e.stopPropagation();
onClose();
break;
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
emblaApi?.scrollPrev();
break;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
emblaApi?.scrollNext();
break;
case '+':
case '=':
e.preventDefault();
e.stopPropagation();
handleZoomIn();
break;
case '-':
e.preventDefault();
e.stopPropagation();
handleZoomOut();
break;
case 'r':
case 'R':
e.preventDefault();
e.stopPropagation();
handleRotate();
break;
case '0':
e.preventDefault();
e.stopPropagation();
handleReset();
break;
case '?':
e.preventDefault();
e.stopPropagation();
setShowHelp((prev) => !prev);
break;
}
};
// 使用 capture 阶段捕获事件,优先级更高
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, emblaApi, onClose, handleZoomIn, handleZoomOut, handleRotate, handleReset]);
// 使用 use-gesture 处理所有手势
// 注意:必须使用 target 选项才能正确使用 preventDefault
useGesture(
{
// 拖拽手势 - 用于移动图片或切换图片
onDrag: ({ offset: [ox, oy], active }) => {
const currentScale = scale.get();
// 如果图片放大了,拖拽用于移动图片
if (currentScale > 1) {
x.set(ox);
y.set(oy);
}
else if (!active) {
// 重置位置切换的逻辑embla-carousel-react处理这里不用管
animate(x, 0, { duration: 0.3 });
animate(y, 0, { duration: 0.3 });
}
},
// 滚轮手势 - 用于缩放
onWheel: ({ event, delta: [, dy], last }) => {
// 避免在最后一个事件中访问 eventdebounced
if (!last && event) {
event.preventDefault();
}
const currentScale = scaleMotion.get();
const scaleDelta = dy > 0 ? -0.1 : 0.1;
const newScale = Math.max(0.5, Math.min(5, currentScale + scaleDelta));
if (!last) {
scaleMotion.set(newScale);
}
},
// 双指缩放手势 - 移动端
onPinch: ({ offset: [s], origin: [ox, oy], first, memo, last }) => {
if (first) {
const currentScale = scaleMotion.get();
const currentX = xMotion.get();
const currentY = yMotion.get();
return [currentScale, currentX, currentY, ox, oy];
}
const [initialScale, initialX, initialY, initialOx, initialOy] = memo;
const newScale = Math.max(0.5, Math.min(5, s));
// 计算缩放中心偏移
if (imageRef.current) {
const rect = imageRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// 相对于图片中心的偏移
const offsetX = ox - centerX;
const offsetY = oy - centerY;
// 根据缩放调整位置,使缩放中心保持在手指位置
const scaleRatio = newScale / initialScale;
xMotion.set(initialX + offsetX * (1 - scaleRatio));
yMotion.set(initialY + offsetY * (1 - scaleRatio));
}
if (!last) {
scaleMotion.set(newScale);
}
return memo;
},
},
{
target: imageRef,
drag: {
from: () => [x.get(), y.get()],
bounds: (state) => {
const currentScale = scale.get();
if (currentScale <= 1) {
// 未放大时,允许左右拖拽切换
return { left: -200, right: 200, top: 0, bottom: 0 };
}
// 放大时不限制边界
return { left: -Infinity, right: Infinity, top: -Infinity, bottom: Infinity };
},
},
pinch: {
scaleBounds: { min: 0.5, max: 5 },
eventOptions: { passive: false },
},
wheel: {
eventOptions: { passive: false },
},
}
);
// 缩略图容器的拖动手势
useGesture(
{
onDrag: ({ movement: [mx], memo = thumbnailRef.current?.scrollLeft ?? 0 }) => {
if (thumbnailRef.current) {
thumbnailRef.current.scrollLeft = memo - mx;
}
return memo;
},
},
{
target: thumbnailRef,
drag: {
axis: 'x',
filterTaps: true,
},
}
);
// 下载当前文件
const handleDownloadCurrent = useCallback(() => {
const currentFile = files[currentIndex];
if (currentFile) {
if (onDownload) {
onDownload(currentFile.id, currentFile);
} else if (currentFile.file) {
downloadFromFile(currentFile.file);
}
}
}, [files, currentIndex, onDownload]);
if (!mounted || !open) return null;
const currentFile = files[currentIndex];
const canScrollPrev = emblaApi?.canScrollPrev() ?? false;
const canScrollNext = emblaApi?.canScrollNext() ?? false;
return createPortal(
<div
ref={containerRef}
className="fixed inset-0 z-70 flex items-center justify-center bg-black/95 pointer-events-auto"
onKeyDown={(e) => {
// 阻止键盘事件冒泡到父组件
e.stopPropagation();
}}
role="dialog"
aria-modal="true"
aria-label="文件预览"
>
{/* 顶部工具栏 */}
<div
className="absolute top-0 left-0 right-0 z-10 flex items-start gap-2 p-4 bg-gradient-to-b from-black/50 to-transparent pointer-events-none"
>
<span className="text-sm font-medium text-white shrink-0">
{currentIndex + 1} / {files.length}
</span>
{currentFile && (
<span className="text-sm text-gray-300 break-words flex-1 min-w-0">
{currentFile.name}
</span>
)}
<div className="flex items-center gap-2 shrink-0 pointer-events-auto">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
setShowHelp((prev) => !prev);
}}
className="text-white hover:bg-white/20"
title="快捷键帮助 (?)"
>
<HelpCircle className='size-5'/>
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
onClose();
}}
className="text-white hover:bg-white/20"
title="关闭 (ESC)"
>
<X className="size-5" />
</Button>
</div>
</div>
{/* 快捷键帮助面板 */}
{showHelp && (
<div
className="absolute top-20 right-4 z-20 bg-black/90 text-white p-4 rounded-lg text-sm space-y-3 max-w-xs pointer-events-none"
>
<h3 className="font-semibold mb-2"></h3>
<div className="space-y-2">
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">ESC</Kbd>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<div className="flex gap-1">
<Kbd className="bg-white/10 text-white border border-white/20">
<ArrowLeft className="size-3" />
</Kbd>
<Kbd className="bg-white/10 text-white border border-white/20">
<ArrowRight className="size-3" />
</Kbd>
</div>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<div className="flex gap-1">
<Kbd className="bg-white/10 text-white border border-white/20">
<Plus className="size-3" />
</Kbd>
<Kbd className="bg-white/10 text-white border border-white/20">
<Minus className="size-3" />
</Kbd>
</div>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">R</Kbd>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">0</Kbd>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">?</Kbd>
<span className="text-gray-300">/</span>
</div>
</div>
</div>
)}
{/* 左侧工具栏 */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-2">
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleZoomIn();
}}
disabled={currentScale >= 5}
title="放大 (+)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<ZoomIn className="size-5" />
</Button>
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleZoomOut();
}}
disabled={currentScale <= 0.5}
title="缩小 (-)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<ZoomOut className="size-5" />
</Button>
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleRotate();
}}
title="旋转 (R)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<RotateCw className="size-5" />
</Button>
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleReset();
}}
title="重置 (0)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<Maximize2 className="size-5" />
</Button>
{(currentFile?.file || onDownload) && (
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleDownloadCurrent();
}}
title="下载"
className="bg-black/50 hover:bg-black/70 text-white"
>
<Download className="size-5" />
</Button>
)}
</div>
{/* 轮播容器 */}
<div className="relative w-full h-full flex items-center justify-center">
<div ref={emblaRef} className="overflow-hidden w-full h-full">
<div className="flex h-full gap-8">
{files.map((file, index) => (
<div
key={file.id}
className="flex-[0_0_100%] min-w-0 flex items-center justify-center"
>
<div
ref={index === currentIndex ? imageRef : null}
className="relative flex items-center justify-center w-full h-full touch-none"
style={{
cursor: index === currentIndex && scale.get() > 1 ? 'grab' : 'default',
}}
>
{file.type?.startsWith('image/') && file.preview ? (
<motion.img
src={file.preview}
alt={file.name}
className="max-w-full max-h-full object-contain select-none"
style={
index === currentIndex
? {
scale,
x,
y,
rotate: rotation,
}
: {}
}
draggable={false}
/>
) : (
<div className="flex flex-col items-center justify-center gap-4 text-white">
{getFileIcon(file.type)}
<div className="text-center">
<p className="text-lg font-medium">{file.name}</p>
<p className="text-sm text-gray-400">{formatBytes(file.size)}</p>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* 底部导航区域:左右切换按钮 + 缩略图 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
{/* 左切换按钮 */}
<Button
variant="secondary"
size="icon"
onClick={() => emblaApi?.scrollPrev()}
disabled={!canScrollPrev}
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
title="上一张 (←)"
>
<ChevronLeft className="size-5" />
</Button>
{/* 缩略图导航 */}
<div
ref={thumbnailRef}
className="flex gap-2 max-w-[70vw] overflow-x-auto scrollbar-muted p-2 bg-black/50 rounded-lg cursor-grab active:cursor-grabbing touch-pan-x"
>
{files.map((file, index) => (
<button
key={file.id}
onClick={() => emblaApi?.scrollTo(index)}
className={cn(
'relative size-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all cursor-pointer select-none',
index === currentIndex
? 'border-primary ring-2 ring-primary'
: 'border-transparent hover:border-white/50'
)}
title={file.name}
>
{file.type?.startsWith('image/') && file.preview ? (
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
{getFileIcon(file.type)}
</div>
)}
</button>
))}
</div>
{/* 右切换按钮 */}
<Button
variant="secondary"
size="icon"
onClick={() => emblaApi?.scrollNext()}
disabled={!canScrollNext}
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
title="下一张 (→)"
>
<ChevronRight className="size-5" />
</Button>
</div>
</div>,
document.body
);
}

Some files were not shown because too many files have changed in this diff Show More