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