forked from admin/hair-keeper
Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
55
.cloud-dev/.dockerignore
Normal file
55
.cloud-dev/.dockerignore
Normal 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
141
.cloud-dev/Dockerfile
Normal 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
335
.cloud-dev/README.md
Normal 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
|
||||
42
.cloud-dev/docker-compose.yml
Normal file
42
.cloud-dev/docker-compose.yml
Normal 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
46
.cloud-dev/entrypoint.sh
Normal 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
61
.env.example
Normal 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
53
.gitignore
vendored
Normal 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
12
.roo/mcp.json
Normal 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
87
README.md
Normal 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)
|
||||
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
||||
- 用户交互增强:motion(动画) + framer-motion(动画) + use-gesture/react(手势)
|
||||
- Headless UI:react-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
83
components.json
Normal 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
43
docker-compose.yml
Normal 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
35
eslint.config.mjs
Normal 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
17
instrumentation.ts
Normal 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
18
next.config.ts
Normal 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
118
package.json
Normal 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
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1102
prisma/init_data/院系.json
Normal file
1102
prisma/init_data/院系.json
Normal file
File diff suppressed because it is too large
Load Diff
223
prisma/migrations/20251113071821_init/migration.sql
Normal file
223
prisma/migrations/20251113071821_init/migration.sql
Normal 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;
|
||||
@@ -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")
|
||||
);
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
243
prisma/schema.prisma
Normal 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
180
prisma/seed.ts
Normal 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)
|
||||
})
|
||||
9
public/js/browser-image-compression.js
Normal file
9
public/js/browser-image-compression.js
Normal file
File diff suppressed because one or more lines are too long
BIN
public/pku_icon.png
Normal file
BIN
public/pku_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
public/pku_logo.png
Normal file
BIN
public/pku_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
public/pku_logo2.png
Normal file
BIN
public/pku_logo2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
122
src/app/(auth)/login/page.tsx
Normal file
122
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/app/(main)/[...notFound]/page.tsx
Normal file
5
src/app/(main)/[...notFound]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export default function NotFoundCatchAll() {
|
||||
notFound()
|
||||
}
|
||||
9
src/app/(main)/dev/arch/layout.dev.tsx
Normal file
9
src/app/(main)/dev/arch/layout.dev.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
297
src/app/(main)/dev/arch/package/page.dev.tsx
Normal file
297
src/app/(main)/dev/arch/package/page.dev.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
src/app/(main)/dev/arch/page.dev.tsx
Normal file
5
src/app/(main)/dev/arch/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function ArchPage() {
|
||||
return <SubMenuRedirect parentHref="/dev/arch" />;
|
||||
}
|
||||
68
src/app/(main)/dev/dev-theme.css
Normal file
68
src/app/(main)/dev/dev-theme.css
Normal 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);
|
||||
}
|
||||
387
src/app/(main)/dev/file/columns.tsx
Normal file
387
src/app/(main)/dev/file/columns.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
192
src/app/(main)/dev/file/components/FileAnalyzeDialog.tsx
Normal file
192
src/app/(main)/dev/file/components/FileAnalyzeDialog.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
210
src/app/(main)/dev/file/components/FileDependencyGraph.tsx
Normal file
210
src/app/(main)/dev/file/components/FileDependencyGraph.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
432
src/app/(main)/dev/file/components/FileDetailPanel.tsx
Normal file
432
src/app/(main)/dev/file/components/FileDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/app/(main)/dev/file/components/FileDetailSheet.tsx
Normal file
66
src/app/(main)/dev/file/components/FileDetailSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
src/app/(main)/dev/file/components/GitCommitViewDialog.tsx
Normal file
129
src/app/(main)/dev/file/components/GitCommitViewDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
169
src/app/(main)/dev/file/directory-tree/page.dev.tsx
Normal file
169
src/app/(main)/dev/file/directory-tree/page.dev.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/app/(main)/dev/file/graph/page.dev.tsx
Normal file
61
src/app/(main)/dev/file/graph/page.dev.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/app/(main)/dev/file/layout.dev.tsx
Normal file
9
src/app/(main)/dev/file/layout.dev.tsx
Normal 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>;
|
||||
}
|
||||
289
src/app/(main)/dev/file/list/page.dev.tsx
Normal file
289
src/app/(main)/dev/file/list/page.dev.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/app/(main)/dev/file/page.dev.tsx
Normal file
5
src/app/(main)/dev/file/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function FilePage() {
|
||||
return <SubMenuRedirect parentHref="/dev/file" />;
|
||||
}
|
||||
9
src/app/(main)/dev/frontend-design/layout.dev.tsx
Normal file
9
src/app/(main)/dev/frontend-design/layout.dev.tsx
Normal 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>;
|
||||
}
|
||||
5
src/app/(main)/dev/frontend-design/page.dev.tsx
Normal file
5
src/app/(main)/dev/frontend-design/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function FrontendDesignPage() {
|
||||
return <SubMenuRedirect parentHref="/dev/frontend-design" />;
|
||||
}
|
||||
19
src/app/(main)/dev/frontend-design/page/page.dev.tsx
Normal file
19
src/app/(main)/dev/frontend-design/page/page.dev.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
411
src/app/(main)/dev/frontend-design/ui/page.dev.tsx
Normal file
411
src/app/(main)/dev/frontend-design/ui/page.dev.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/(main)/dev/layout.dev.tsx
Normal file
15
src/app/(main)/dev/layout.dev.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import "./dev-theme.css";
|
||||
|
||||
export default function DevLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
src/app/(main)/dev/panel/agents-config.tsx
Normal file
88
src/app/(main)/dev/panel/agents-config.tsx
Normal 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)
|
||||
}
|
||||
584
src/app/(main)/dev/panel/components/version-control.tsx
Normal file
584
src/app/(main)/dev/panel/components/version-control.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
407
src/app/(main)/dev/panel/dev-ai-chat.tsx
Normal file
407
src/app/(main)/dev/panel/dev-ai-chat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/app/(main)/dev/panel/dev-checklist.tsx
Normal file
30
src/app/(main)/dev/panel/dev-checklist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
59
src/app/(main)/dev/panel/dev-panel-provider.tsx
Normal file
59
src/app/(main)/dev/panel/dev-panel-provider.tsx
Normal 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
|
||||
}
|
||||
47
src/app/(main)/dev/panel/dev-panel.tsx
Normal file
47
src/app/(main)/dev/panel/dev-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
252
src/app/(main)/dev/panel/dev-tools.tsx
Normal file
252
src/app/(main)/dev/panel/dev-tools.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
src/app/(main)/dev/panel/index.ts
Normal file
5
src/app/(main)/dev/panel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 开发者面板本质是个drawer组件而不是单独的页面,在这个文件中导出以便使用
|
||||
*/
|
||||
|
||||
export { DevPanel } from './dev-panel'
|
||||
19
src/app/(main)/dev/run/container/page.dev.tsx
Normal file
19
src/app/(main)/dev/run/container/page.dev.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/(main)/dev/run/layout.dev.tsx
Normal file
9
src/app/(main)/dev/run/layout.dev.tsx
Normal 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>;
|
||||
}
|
||||
5
src/app/(main)/dev/run/page.dev.tsx
Normal file
5
src/app/(main)/dev/run/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function RunPage() {
|
||||
return <SubMenuRedirect parentHref="/dev/run" />;
|
||||
}
|
||||
26
src/app/(main)/error/403/page.tsx
Normal file
26
src/app/(main)/error/403/page.tsx
Normal 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
32
src/app/(main)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/app/(main)/not-found.tsx
Normal file
26
src/app/(main)/not-found.tsx
Normal 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
375
src/app/(main)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/app/(main)/settings/page.tsx
Normal file
35
src/app/(main)/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
260
src/app/(main)/users/columns.tsx
Normal file
260
src/app/(main)/users/columns.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
199
src/app/(main)/users/components/BatchAuthorizationDialog.tsx
Normal file
199
src/app/(main)/users/components/BatchAuthorizationDialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
397
src/app/(main)/users/components/RoleManagementDialog.tsx
Normal file
397
src/app/(main)/users/components/RoleManagementDialog.tsx
Normal 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">
|
||||
确定要删除角色 "{role.name}" 吗?
|
||||
</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>
|
||||
)
|
||||
}
|
||||
214
src/app/(main)/users/components/UserCreateDialog.tsx
Normal file
214
src/app/(main)/users/components/UserCreateDialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
87
src/app/(main)/users/components/UserDeleteDialog.tsx
Normal file
87
src/app/(main)/users/components/UserDeleteDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
src/app/(main)/users/components/UserUpdateDialog.tsx
Normal file
229
src/app/(main)/users/components/UserUpdateDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
src/app/(main)/users/page.tsx
Normal file
150
src/app/(main)/users/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/app/(main)/welcome.tsx
Normal file
43
src/app/(main)/welcome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal 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 }
|
||||
165
src/app/api/dev/ai-chat/route.dev.ts
Normal file
165
src/app/api/dev/ai-chat/route.dev.ts
Normal 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' }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
13
src/app/api/trpc/[trpc]/route.ts
Normal file
13
src/app/api/trpc/[trpc]/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
176
src/app/globals.css
Normal file
176
src/app/globals.css
Normal 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
51
src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/components/ai-elements/actions.tsx
Normal file
65
src/components/ai-elements/actions.tsx
Normal 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;
|
||||
};
|
||||
97
src/components/ai-elements/conversation.tsx
Normal file
97
src/components/ai-elements/conversation.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
};
|
||||
451
src/components/ai-elements/message.tsx
Normal file
451
src/components/ai-elements/message.tsx
Normal 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>
|
||||
);
|
||||
205
src/components/ai-elements/model-selector.tsx
Normal file
205
src/components/ai-elements/model-selector.tsx
Normal 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} />
|
||||
);
|
||||
1190
src/components/ai-elements/prompt-input.tsx
Normal file
1190
src/components/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
178
src/components/ai-elements/reasoning.tsx
Normal file
178
src/components/ai-elements/reasoning.tsx
Normal 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";
|
||||
22
src/components/ai-elements/response.tsx
Normal file
22
src/components/ai-elements/response.tsx
Normal 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";
|
||||
64
src/components/ai-elements/shimmer.tsx
Normal file
64
src/components/ai-elements/shimmer.tsx
Normal 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);
|
||||
215
src/components/common/advanced-select-provider.tsx
Normal file
215
src/components/common/advanced-select-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
399
src/components/common/advanced-select.tsx
Normal file
399
src/components/common/advanced-select.tsx
Normal 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 }
|
||||
271
src/components/common/card-select.tsx
Normal file
271
src/components/common/card-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/components/common/checkbox-group.tsx
Normal file
67
src/components/common/checkbox-group.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
src/components/common/date-picker.tsx
Normal file
121
src/components/common/date-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/components/common/date-range-picker.tsx
Normal file
118
src/components/common/date-range-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
968
src/components/common/file-preview.tsx
Normal file
968
src/components/common/file-preview.tsx
Normal 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-100),undefined 表示未上传或已完成 */
|
||||
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 }) => {
|
||||
// 避免在最后一个事件中访问 event(debounced)
|
||||
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
Reference in New Issue
Block a user