commit 42be39b343ec9d0dfb4568d4a43d00ab2005f3bf Author: liuyh Date: Thu Nov 13 15:24:54 2025 +0800 Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑 diff --git a/.cloud-dev/.dockerignore b/.cloud-dev/.dockerignore new file mode 100644 index 0000000..30f8843 --- /dev/null +++ b/.cloud-dev/.dockerignore @@ -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 \ No newline at end of file diff --git a/.cloud-dev/Dockerfile b/.cloud-dev/Dockerfile new file mode 100644 index 0000000..ccb810d --- /dev/null +++ b/.cloud-dev/Dockerfile @@ -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"] \ No newline at end of file diff --git a/.cloud-dev/README.md b/.cloud-dev/README.md new file mode 100644 index 0000000..e4d0012 --- /dev/null +++ b/.cloud-dev/README.md @@ -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 \ No newline at end of file diff --git a/.cloud-dev/docker-compose.yml b/.cloud-dev/docker-compose.yml new file mode 100644 index 0000000..ca25a48 --- /dev/null +++ b/.cloud-dev/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/.cloud-dev/entrypoint.sh b/.cloud-dev/entrypoint.sh new file mode 100644 index 0000000..de523b4 --- /dev/null +++ b/.cloud-dev/entrypoint.sh @@ -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 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9baef90 --- /dev/null +++ b/.env.example @@ -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中) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac730c5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.roo/mcp.json b/.roo/mcp.json new file mode 100644 index 0000000..7a61061 --- /dev/null +++ b/.roo/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers":{ + "ai-elements": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://registry.ai-sdk.dev/api/mcp" + ] + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c61bbb --- /dev/null +++ b/README.md @@ -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进行分析 diff --git a/components.json b/components.json new file mode 100644 index 0000000..6165388 --- /dev/null +++ b/components.json @@ -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" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fa09b9 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..6e8754c --- /dev/null +++ b/eslint.config.mjs @@ -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; diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..f19f9a2 --- /dev/null +++ b/instrumentation.ts @@ -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 { + + } +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..07f18a0 --- /dev/null +++ b/next.config.ts @@ -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); diff --git a/package.json b/package.json new file mode 100644 index 0000000..c544a63 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/prisma/init_data/院系.json b/prisma/init_data/院系.json new file mode 100644 index 0000000..1654eb2 --- /dev/null +++ b/prisma/init_data/院系.json @@ -0,0 +1,1102 @@ +[ + { + "id": "00001", + "name": "数学学院", + "full_name": "数学科学学院" + }, + { + "id": "00004", + "name": "物理学院", + "full_name": "物理学院" + }, + { + "id": "00010", + "name": "化学学院", + "full_name": "化学与分子工程学院" + }, + { + "id": "00011", + "name": "生命学院", + "full_name": "生命科学学院" + }, + { + "id": "00012", + "name": "地空学院", + "full_name": "地球与空间科学学院" + }, + { + "id": "00013", + "name": "环科学院", + "full_name": "环境科学与工程学院" + }, + { + "id": "00016", + "name": "心理学院", + "full_name": "心理与认知科学学院" + }, + { + "id": "00017", + "name": "软件学院", + "full_name": "软件与微电子学院" + }, + { + "id": "00018", + "name": "新闻学院", + "full_name": "新闻与传播学院" + }, + { + "id": "00020", + "name": "中文系", + "full_name": "中国语言文学系" + }, + { + "id": "00021", + "name": "历史系", + "full_name": "历史学系" + }, + { + "id": "00022", + "name": "考古文博学院", + "full_name": "考古文博学院" + }, + { + "id": "00023", + "name": "哲学宗教学系", + "full_name": "哲学系、宗教学系" + }, + { + "id": "00024", + "name": "国际关系学院", + "full_name": "国际关系学院" + }, + { + "id": "00025", + "name": "经济学院", + "full_name": "经济学院" + }, + { + "id": "00028", + "name": "光华管理学院", + "full_name": "光华管理学院" + }, + { + "id": "00029", + "name": "法学院", + "full_name": "法学院" + }, + { + "id": "00030", + "name": "信息管理系", + "full_name": "信息管理系" + }, + { + "id": "00031", + "name": "社会学系", + "full_name": "社会学系" + }, + { + "id": "00032", + "name": "政府管理学院", + "full_name": "政府管理学院" + }, + { + "id": "00033", + "name": "核磁中心", + "full_name": "北京核磁共振中心" + }, + { + "id": "00034", + "name": "成人教育学院", + "full_name": "成人教育学院" + }, + { + "id": "00039", + "name": "外国语学院", + "full_name": "外国语学院" + }, + { + "id": "00040", + "name": "马克思学院", + "full_name": "马克思主义学院" + }, + { + "id": "00041", + "name": "体育教研部", + "full_name": "体育教研部" + }, + { + "id": "00042", + "name": "科学与社会研究中心", + "full_name": "科学与社会研究中心" + }, + { + "id": "00043", + "name": "艺术学院", + "full_name": "艺术学院" + }, + { + "id": "00044", + "name": "对外汉语学院", + "full_name": "对外汉语教育学院" + }, + { + "id": "00045", + "name": "教师教学发展中心", + "full_name": "教师教学发展中心" + }, + { + "id": "00046", + "name": "元培学院", + "full_name": "元培学院" + }, + { + "id": "00047", + "name": "深圳研究生院", + "full_name": "深圳研究生院" + }, + { + "id": "00048", + "name": "信息学院", + "full_name": "信息科学技术学院" + }, + { + "id": "00049", + "name": "景观设计学研究院", + "full_name": "景观设计学研究院" + }, + { + "id": "00055", + "name": "王选所", + "full_name": "王选计算机研究所" + }, + { + "id": "00058", + "name": "电镜实验室", + "full_name": "物理学院电子显微镜室" + }, + { + "id": "00062", + "name": "国发院", + "full_name": "国家发展研究院" + }, + { + "id": "00063", + "name": "马列所", + "full_name": "马列主义研究所" + }, + { + "id": "00067", + "name": "教育学院", + "full_name": "教育学院" + }, + { + "id": "00068", + "name": "人口所", + "full_name": "人口研究所" + }, + { + "id": "00069", + "name": "社会学所", + "full_name": "社会学人类学研究所" + }, + { + "id": "00074", + "name": "现代物理中心", + "full_name": "北京现代物理研究中心" + }, + { + "id": "00082", + "name": "计算中心", + "full_name": "计算中心" + }, + { + "id": "00084", + "name": "交叉学科院", + "full_name": "前沿交叉学科研究院" + }, + { + "id": "00086", + "name": "工学院", + "full_name": "工学院" + }, + { + "id": "00087", + "name": "中古史中心", + "full_name": "中国中古史研究中心" + }, + { + "id": "00099", + "name": "图书馆", + "full_name": "图书馆" + }, + { + "id": "00100", + "name": "集成电路学院", + "full_name": "集成电路学院" + }, + { + "id": "00101", + "name": "计算机学院", + "full_name": "计算机学院" + }, + { + "id": "00106", + "name": "智能学院", + "full_name": "智能学院" + }, + { + "id": "00107", + "name": "电子学院", + "full_name": "电子学院" + }, + { + "id": "00126", + "name": "城环学院", + "full_name": "城市与环境学院" + }, + { + "id": "00127", + "name": "环境学院", + "full_name": "环境科学与工程学院" + }, + { + "id": "00152", + "name": "经济研究所", + "full_name": "经济研究所" + }, + { + "id": "00160", + "name": "亚太研究院", + "full_name": "亚太研究院" + }, + { + "id": "00165", + "name": "首都发展研究院", + "full_name": "首都发展研究院" + }, + { + "id": "00166", + "name": "国学研究院", + "full_name": "国学研究院" + }, + { + "id": "00167", + "name": "民营经济院", + "full_name": "民营经济研究院" + }, + { + "id": "00168", + "name": "中古文献中心", + "full_name": "中国古文献研究中心" + }, + { + "id": "00169", + "name": "中国古代史研究中心", + "full_name": "中国古代史研究中心" + }, + { + "id": "00170", + "name": "汉语语言学研究中心", + "full_name": "汉语语言学研究中心" + }, + { + "id": "00171", + "name": "东方文学中心", + "full_name": "东方文学研究中心" + }, + { + "id": "00172", + "name": "中国考古学研究中心", + "full_name": "中国考古学研究中心" + }, + { + "id": "00173", + "name": "外国哲学研究所", + "full_name": "外国哲学研究所" + }, + { + "id": "00174", + "name": "政治发展与政府管理研究所", + "full_name": "政治发展与政府管理研究所" + }, + { + "id": "00175", + "name": "社会发展中心", + "full_name": "中国社会与发展研究中心" + }, + { + "id": "00176", + "name": "邓小平理论研究院", + "full_name": "邓小平理论研究院" + }, + { + "id": "00177", + "name": "教育经济研究所", + "full_name": "教育经济研究所" + }, + { + "id": "00178", + "name": "经济法研究所", + "full_name": "经济法研究所" + }, + { + "id": "00179", + "name": "产学研基地", + "full_name": "深港产学研基地" + }, + { + "id": "00180", + "name": "医学部教学办", + "full_name": "医学部教学办" + }, + { + "id": "00181", + "name": "科学计算中心", + "full_name": "科学与工程计算中心" + }, + { + "id": "00182", + "name": "分子医学所", + "full_name": "分子医学研究所" + }, + { + "id": "00183", + "name": "软件工程中心", + "full_name": "软件工程国家工程研究中心" + }, + { + "id": "00184", + "name": "实验动物中心", + "full_name": "实验动物中心" + }, + { + "id": "00185", + "name": "先进技术院", + "full_name": "先进技术研究院" + }, + { + "id": "00187", + "name": "社科调查中心", + "full_name": "中国社会科学调查中心" + }, + { + "id": "00188", + "name": "财政所", + "full_name": "中国教育财政科学研究所" + }, + { + "id": "00189", + "name": "科维理天文所", + "full_name": "科维理天文与天体物理研究所" + }, + { + "id": "00190", + "name": "中外妇女中心", + "full_name": "中外妇女问题研究中心" + }, + { + "id": "00191", + "name": "数学中心", + "full_name": "北京国际数学研究中心" + }, + { + "id": "00192", + "name": "歌剧研究院", + "full_name": "歌剧研究院" + }, + { + "id": "00195", + "name": "景观学院", + "full_name": "建筑与景观设计学院" + }, + { + "id": "00197", + "name": "中国画法研究院", + "full_name": "中国画法研究院" + }, + { + "id": "00198", + "name": "西方古典学中心", + "full_name": "西方古典学中心" + }, + { + "id": "00201", + "name": "校办", + "full_name": "校长办公室" + }, + { + "id": "00202", + "name": "人文院", + "full_name": "高等人文研究院" + }, + { + "id": "00203", + "name": "麦戈文脑科学研究所", + "full_name": "北京大学麦戈文脑科学研究所" + }, + { + "id": "00204", + "name": "继续教育学院", + "full_name": "继续教育学院" + }, + { + "id": "00205", + "name": "国际战略研究院", + "full_name": "国际战略研究院" + }, + { + "id": "00206", + "name": "新媒体研究院", + "full_name": "北京大学新媒体研究院" + }, + { + "id": "00207", + "name": "海洋研究院", + "full_name": "海洋研究院" + }, + { + "id": "00208", + "name": "燕京学堂", + "full_name": "燕京学堂" + }, + { + "id": "00210", + "name": "监察室", + "full_name": "监察室" + }, + { + "id": "00211", + "name": "现代农学院", + "full_name": "现代农学院" + }, + { + "id": "00212", + "name": "新结构经济学研究中心", + "full_name": "新结构经济学研究中心" + }, + { + "id": "00213", + "name": "中国政治学研究中心", + "full_name": "北京大学中国政治学研究中心" + }, + { + "id": "00214", + "name": "儒藏编纂中心", + "full_name": "儒藏编纂与研究中心" + }, + { + "id": "00215", + "name": "基金会", + "full_name": "基金会" + }, + { + "id": "00216", + "name": "人文社会科学研究院", + "full_name": "人文社会科学研究院" + }, + { + "id": "00220", + "name": "微米纳米加工技术全国重点实验室", + "full_name": "微米纳米加工技术全国重点实验室" + }, + { + "id": "00221", + "name": "新时代研究院", + "full_name": "北京大学习近平新时代中国特色社会主义思想研究院" + }, + { + "id": "00222", + "name": "生物医学前沿创新中心", + "full_name": "北京大学生物医学前沿创新中心" + }, + { + "id": "00223", + "name": "天然气水合物国际研究中心", + "full_name": "北京大学天然气水合物国际研究中心" + }, + { + "id": "00225", + "name": "人工智能研究院", + "full_name": "北京大学人工智能研究院" + }, + { + "id": "00228", + "name": "能源研究院", + "full_name": "北京大学能源研究院" + }, + { + "id": "00230", + "name": "大数据实验室", + "full_name": "北京大学大数据分析与应用技术国家工程实验室" + }, + { + "id": "00231", + "name": "全健院", + "full_name": "北京大学全球健康发展研究院" + }, + { + "id": "00232", + "name": "材料科学与工程学院", + "full_name": "北京大学材料科学与工程学院" + }, + { + "id": "00233", + "name": "未来技术学院", + "full_name": "北京大学未来技术学院" + }, + { + "id": "00234", + "name": "文学讲习所", + "full_name": "北京大学文学讲习所" + }, + { + "id": "00236", + "name": "现代中国人文研究所", + "full_name": "北京大学现代中国人文研究所" + }, + { + "id": "00237", + "name": "碳中和研究院", + "full_name": "北京大学碳中和研究院" + }, + { + "id": "00301", + "name": "教务长办", + "full_name": "教务长办公室" + }, + { + "id": "00302", + "name": "成人教育部", + "full_name": "成人教育部" + }, + { + "id": "00303", + "name": "教务处", + "full_name": "教务处" + }, + { + "id": "00304", + "name": "自然科学处", + "full_name": "自然科学处" + }, + { + "id": "00305", + "name": "社科处", + "full_name": "社会科学处" + }, + { + "id": "00307", + "name": "科技开发部", + "full_name": "科技开发部" + }, + { + "id": "00308", + "name": "研究生院", + "full_name": "研究生院" + }, + { + "id": "00309", + "name": "昌平园区", + "full_name": "昌平园区" + }, + { + "id": "00310", + "name": "海外教育学院", + "full_name": "海外教育学院" + }, + { + "id": "00311", + "name": "境外办", + "full_name": "境外办学办公室" + }, + { + "id": "00312", + "name": "知识产权学院", + "full_name": "知识产权学院第一届董事会" + }, + { + "id": "00320", + "name": "学报", + "full_name": "学报" + }, + { + "id": "00322", + "name": "环境保护办公", + "full_name": "环境保护办公室" + }, + { + "id": "00351", + "name": "学位办", + "full_name": "学位办公室" + }, + { + "id": "00352", + "name": "研管处", + "full_name": "研究生管理处" + }, + { + "id": "00353", + "name": "研培处", + "full_name": "研究生培养处" + }, + { + "id": "00360", + "name": "成人教育", + "full_name": "成人教育学院" + }, + { + "id": "00381", + "name": "考试中心", + "full_name": "国外考试中心" + }, + { + "id": "00390", + "name": "专利事务所", + "full_name": "专利事务所" + }, + { + "id": "00398", + "name": "口腔实验室", + "full_name": "口腔数字化医疗技术和材料国家工程实验室" + }, + { + "id": "00399", + "name": "生命科学中心", + "full_name": "北京大学生命科学联合中心" + }, + { + "id": "00401", + "name": "总务长办", + "full_name": "总务长办公室" + }, + { + "id": "00402", + "name": "财务处", + "full_name": "财务处" + }, + { + "id": "00403", + "name": "基建处", + "full_name": "基建处" + }, + { + "id": "00405", + "name": "动力中心", + "full_name": "动力中心" + }, + { + "id": "00406", + "name": "餐饮中心", + "full_name": "餐饮中心" + }, + { + "id": "00407", + "name": "校园管理中心", + "full_name": "校园管理服务中心" + }, + { + "id": "00408", + "name": "会议中心", + "full_name": "会议中心" + }, + { + "id": "00409", + "name": "校医院", + "full_name": "校医院" + }, + { + "id": "00410", + "name": "运输中心", + "full_name": "运输中心" + }, + { + "id": "00411", + "name": "学生宿舍中心", + "full_name": "学生宿舍服务中心" + }, + { + "id": "00412", + "name": "社区服务中心", + "full_name": "社区服务中心" + }, + { + "id": "00413", + "name": "幼教中心", + "full_name": "幼教中心" + }, + { + "id": "00414", + "name": "电话室", + "full_name": "电话室" + }, + { + "id": "00415", + "name": "水电中心", + "full_name": "水电中心" + }, + { + "id": "00416", + "name": "供暖中心", + "full_name": "供暖中心" + }, + { + "id": "00418", + "name": "特殊用房中心", + "full_name": "特殊用房管理中心" + }, + { + "id": "00420", + "name": "肖家河建设办", + "full_name": "肖家河项目建设办公室" + }, + { + "id": "00422", + "name": "校园服务中心", + "full_name": "校园服务中心" + }, + { + "id": "00423", + "name": "公寓服务中心", + "full_name": "公寓服务中心" + }, + { + "id": "00424", + "name": "北京大学附属幼儿园", + "full_name": "北京大学附属幼儿园" + }, + { + "id": "00500", + "name": "校庆办公室", + "full_name": "百年校庆筹备委员会办公室" + }, + { + "id": "00501", + "name": "职工学校", + "full_name": "职工业余学校" + }, + { + "id": "00503", + "name": "一附中", + "full_name": "北大附中" + }, + { + "id": "00504", + "name": "二附中", + "full_name": "北大第二附属中学" + }, + { + "id": "00505", + "name": "附小", + "full_name": "北大附小" + }, + { + "id": "00510", + "name": "印刷厂", + "full_name": "印刷厂" + }, + { + "id": "00511", + "name": "出版社", + "full_name": "出版社" + }, + { + "id": "00551", + "name": "街道办", + "full_name": "燕园街道办事处" + }, + { + "id": "00552", + "name": "派出所", + "full_name": "燕园派出所" + }, + { + "id": "00601", + "name": "党办校办", + "full_name": "党委办公室校长办公室" + }, + { + "id": "00602", + "name": "政策法规研究室", + "full_name": "政策法规研究室" + }, + { + "id": "00603", + "name": "纪委监察室", + "full_name": "纪律检查委员会监察室" + }, + { + "id": "00604", + "name": "组织部", + "full_name": "党委组织部" + }, + { + "id": "00605", + "name": "宣传部", + "full_name": "党委宣传部" + }, + { + "id": "00606", + "name": "统战部", + "full_name": "党委统战部" + }, + { + "id": "00607", + "name": "武装部学工部", + "full_name": "武装部学生工作部" + }, + { + "id": "00608", + "name": "保卫部", + "full_name": "保卫部" + }, + { + "id": "00609", + "name": "人事部", + "full_name": "人事部" + }, + { + "id": "00610", + "name": "国际合作部", + "full_name": "国际合作部" + }, + { + "id": "00611", + "name": "财务部", + "full_name": "财务部" + }, + { + "id": "00612", + "name": "教务部", + "full_name": "教务部" + }, + { + "id": "00613", + "name": "科研部", + "full_name": "科学研究部" + }, + { + "id": "00614", + "name": "研究生院", + "full_name": "研究生院" + }, + { + "id": "00615", + "name": "继续教育部", + "full_name": "继续教育部" + }, + { + "id": "00616", + "name": "房产部", + "full_name": "房地产管理部" + }, + { + "id": "00617", + "name": "基建部", + "full_name": "基建工程部" + }, + { + "id": "00618", + "name": "总务部", + "full_name": "总务部" + }, + { + "id": "00619", + "name": "产业管理部", + "full_name": "产业管理部" + }, + { + "id": "00620", + "name": "社科部", + "full_name": "社会科学部" + }, + { + "id": "00621", + "name": "审计室", + "full_name": "审计室" + }, + { + "id": "00622", + "name": "科技开发部", + "full_name": "科技开发部" + }, + { + "id": "00623", + "name": "设备部", + "full_name": "实验室与设备管理部" + }, + { + "id": "00626", + "name": "保密办", + "full_name": "保密委员会办公室" + }, + { + "id": "00628", + "name": "校友办公室", + "full_name": "校友工作办公室" + }, + { + "id": "00629", + "name": "离退休工作部", + "full_name": "离退休工作部" + }, + { + "id": "00632", + "name": "学科办", + "full_name": "学科建设办公室" + }, + { + "id": "00633", + "name": "国内合作办", + "full_name": "北京大学国内合作委员会办公室" + }, + { + "id": "00634", + "name": "巡察办", + "full_name": "北京大学党委巡察办公室" + }, + { + "id": "00636", + "name": "网信办", + "full_name": "网络安全和信息化委员会办公室" + }, + { + "id": "00637", + "name": "怀柔科学城校区筹建办公室", + "full_name": "怀柔科学城校区筹建办公室" + }, + { + "id": "00638", + "name": "北京大学新校区管理委员会办公室", + "full_name": "北京大学新校区管理委员会办公室" + }, + { + "id": "00639", + "name": "教务长办公室", + "full_name": "教务长办公室" + }, + { + "id": "00651", + "name": "校团委", + "full_name": "校团委" + }, + { + "id": "00652", + "name": "工会", + "full_name": "工会" + }, + { + "id": "00653", + "name": "学位办公室", + "full_name": "学位办公室" + }, + { + "id": "00654", + "name": "昌平园区", + "full_name": "昌平园区" + }, + { + "id": "00655", + "name": "教育基金会", + "full_name": "教育基金会" + }, + { + "id": "00657", + "name": "附中", + "full_name": "北京大学附属中学" + }, + { + "id": "00660", + "name": "出版社", + "full_name": "出版社" + }, + { + "id": "00661", + "name": "档案馆", + "full_name": "档案馆" + }, + { + "id": "00662", + "name": "燕园办事处", + "full_name": "燕园街道办事处" + }, + { + "id": "00664", + "name": "校史馆", + "full_name": "校史馆" + }, + { + "id": "00665", + "name": "校友会", + "full_name": "北京大学校友会" + }, + { + "id": "00666", + "name": "信息办", + "full_name": "信息化办公室" + }, + { + "id": "00668", + "name": "体育馆", + "full_name": "北大体育馆临时管理小组" + }, + { + "id": "00669", + "name": "昌平校区", + "full_name": "昌平校区管理办公室" + }, + { + "id": "00670", + "name": "学生资助中心", + "full_name": "学生资助中心" + }, + { + "id": "00671", + "name": "创新创业学院", + "full_name": "北京大学创新创业学院" + }, + { + "id": "00701", + "name": "校产办", + "full_name": "校办产业管理办公室" + }, + { + "id": "00702", + "name": "仪器厂", + "full_name": "仪器厂" + }, + { + "id": "00703", + "name": "电子仪器厂", + "full_name": "电子仪器厂" + }, + { + "id": "00704", + "name": "无线电厂", + "full_name": "无线电工厂" + }, + { + "id": "00705", + "name": "北佳", + "full_name": "北佳新技术有限公司" + }, + { + "id": "00706", + "name": "方正集团", + "full_name": "方正集团" + }, + { + "id": "00707", + "name": "麦普", + "full_name": "麦普微波器件有限公司" + }, + { + "id": "00708", + "name": "未名公司", + "full_name": "未名生物工程总公司" + }, + { + "id": "00790", + "name": "生物城委员会", + "full_name": "生物城建设委员会" + } +] \ No newline at end of file diff --git a/prisma/migrations/20251113071821_init/migration.sql b/prisma/migrations/20251113071821_init/migration.sql new file mode 100644 index 0000000..a1fc864 --- /dev/null +++ b/prisma/migrations/20251113071821_init/migration.sql @@ -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; diff --git a/prisma/migrations/20251114032620_add_kv_config/migration.sql b/prisma/migrations/20251114032620_add_kv_config/migration.sql new file mode 100644 index 0000000..093016c --- /dev/null +++ b/prisma/migrations/20251114032620_add_kv_config/migration.sql @@ -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") +); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..8ed525a --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..3901899 --- /dev/null +++ b/prisma/seed.ts @@ -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 = { + 系统管理员: 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) + }) \ No newline at end of file diff --git a/public/js/browser-image-compression.js b/public/js/browser-image-compression.js new file mode 100644 index 0000000..c7fe920 --- /dev/null +++ b/public/js/browser-image-compression.js @@ -0,0 +1,9 @@ +/** + * Browser Image Compression + * v2.0.2 + * by Donald + * https://github.com/Donaldcwl/browser-image-compression + */ + +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).imageCompression=t()}(this,(function(){"use strict";function _mergeNamespaces(e,t){return t.forEach((function(t){t&&"string"!=typeof t&&!Array.isArray(t)&&Object.keys(t).forEach((function(r){if("default"!==r&&!(r in e)){var i=Object.getOwnPropertyDescriptor(t,r);Object.defineProperty(e,r,i.get?i:{enumerable:!0,get:function(){return t[r]}})}}))})),Object.freeze(e)}function copyExifWithoutOrientation(e,t){return new Promise((function(r,i){let o;return getApp1Segment(e).then((function(e){try{return o=e,r(new Blob([t.slice(0,2),o,t.slice(2)],{type:"image/jpeg"}))}catch(e){return i(e)}}),i)}))}const getApp1Segment=e=>new Promise(((t,r)=>{const i=new FileReader;i.addEventListener("load",(({target:{result:e}})=>{const i=new DataView(e);let o=0;if(65496!==i.getUint16(o))return r("not a valid JPEG");for(o+=2;;){const a=i.getUint16(o);if(65498===a)break;const s=i.getUint16(o+2);if(65505===a&&1165519206===i.getUint32(o+4)){const a=o+10;let f;switch(i.getUint16(a)){case 18761:f=!0;break;case 19789:f=!1;break;default:return r("TIFF header contains invalid endian")}if(42!==i.getUint16(a+2,f))return r("TIFF header contains invalid version");const l=i.getUint32(a+4,f),c=a+l+2+12*i.getUint16(a+l,f);for(let e=a+l+2;e>>24&255,i[r+1]=o>>>16&255,i[r+2]=o>>>8&255,i[r+3]=o>>>0&255,new Uint8Array(i.buffer,0,r+4)},UZIP.deflateRaw=function(e,t){null==t&&(t={level:6});var r=new Uint8Array(50+Math.floor(1.1*e.length)),i=UZIP.F.deflateRaw(e,r,i,t.level);return new Uint8Array(r.buffer,0,i)},UZIP.encode=function(e,t){null==t&&(t=!1);var r=0,i=UZIP.bin.writeUint,o=UZIP.bin.writeUshort,a={};for(var s in e){var f=!UZIP._noNeed(s)&&!t,l=e[s],c=UZIP.crc.crc(l,0,l.length);a[s]={cpr:f,usize:l.length,crc:c,file:f?UZIP.deflateRaw(l):l}}for(var s in a)r+=a[s].file.length+30+46+2*UZIP.bin.sizeUTF8(s);r+=22;var u=new Uint8Array(r),h=0,d=[];for(var s in a){var A=a[s];d.push(h),h=UZIP._writeHeader(u,h,s,A,0)}var g=0,p=h;for(var s in a){A=a[s];d.push(h),h=UZIP._writeHeader(u,h,s,A,1,d[g++])}var m=h-p;return i(u,h,101010256),h+=4,o(u,h+=4,g),o(u,h+=2,g),i(u,h+=2,m),i(u,h+=4,p),h+=4,h+=2,u.buffer},UZIP._noNeed=function(e){var t=e.split(".").pop().toLowerCase();return-1!="png,jpg,jpeg,zip".indexOf(t)},UZIP._writeHeader=function(e,t,r,i,o,a){var s=UZIP.bin.writeUint,f=UZIP.bin.writeUshort,l=i.file;return s(e,t,0==o?67324752:33639248),t+=4,1==o&&(t+=2),f(e,t,20),f(e,t+=2,0),f(e,t+=2,i.cpr?8:0),s(e,t+=2,0),s(e,t+=4,i.crc),s(e,t+=4,l.length),s(e,t+=4,i.usize),f(e,t+=4,UZIP.bin.sizeUTF8(r)),f(e,t+=2,0),t+=2,1==o&&(t+=2,t+=2,s(e,t+=6,a),t+=4),t+=UZIP.bin.writeUTF8(e,t,r),0==o&&(e.set(l,t),t+=l.length),t},UZIP.crc={table:function(){for(var e=new Uint32Array(256),t=0;t<256;t++){for(var r=t,i=0;i<8;i++)1&r?r=3988292384^r>>>1:r>>>=1;e[t]=r}return e}(),update:function(e,t,r,i){for(var o=0;o>>8;return e},crc:function(e,t,r){return 4294967295^UZIP.crc.update(4294967295,e,t,r)}},UZIP.adler=function(e,t,r){for(var i=1,o=0,a=t,s=t+r;a>8&255},readUint:function(e,t){return 16777216*e[t+3]+(e[t+2]<<16|e[t+1]<<8|e[t])},writeUint:function(e,t,r){e[t]=255&r,e[t+1]=r>>8&255,e[t+2]=r>>16&255,e[t+3]=r>>24&255},readASCII:function(e,t,r){for(var i="",o=0;o>6,e[t+o+1]=128|s>>0&63,o+=2;else if(0==(4294901760&s))e[t+o]=224|s>>12,e[t+o+1]=128|s>>6&63,e[t+o+2]=128|s>>0&63,o+=3;else{if(0!=(4292870144&s))throw"e";e[t+o]=240|s>>18,e[t+o+1]=128|s>>12&63,e[t+o+2]=128|s>>6&63,e[t+o+3]=128|s>>0&63,o+=4}}return o},sizeUTF8:function(e){for(var t=e.length,r=0,i=0;i>>3}var d=a.lits,A=a.strt,g=a.prev,p=0,m=0,w=0,v=0,b=0,y=0;for(h>2&&(A[y=UZIP.F._hash(e,0)]=0),l=0;l14e3||m>26697)&&h-l>100&&(u>>16,B=65535&F;if(0!=F){B=65535&F;var U=s(_=F>>>16,a.of0);a.lhst[257+U]++;var C=s(B,a.df0);a.dhst[C]++,v+=a.exb[U]+a.dxb[C],d[p]=_<<23|l-u,d[p+1]=B<<16|U<<8|C,p+=2,u=l+_}else a.lhst[e[l]]++;m++}}for(w==l&&0!=e.length||(u>>3},UZIP.F._bestMatch=function(e,t,r,i,o,a){var s=32767&t,f=r[s],l=s-f+32768&32767;if(f==s||i!=UZIP.F._hash(e,t-l))return 0;for(var c=0,u=0,h=Math.min(32767,t);l<=h&&0!=--a&&f!=s;){if(0==c||e[t+c]==e[t+c-l]){var d=UZIP.F._howLong(e,t,l);if(d>c){if(u=l,(c=d)>=o)break;l+2A&&(A=m,f=p)}}}l+=(s=f)-(f=r[s])+32768&32767}return c<<16|u},UZIP.F._howLong=function(e,t,r){if(e[t]!=e[t-r]||e[t+1]!=e[t+1-r]||e[t+2]!=e[t+2-r])return 0;var i=t,o=Math.min(e.length,t+258);for(t+=3;t>>23,R=M+(8388607&T);M>16,H=O>>8&255,L=255&O;y(f,l=UZIP.F._writeLit(257+H,C,f,l),S-v.of0[H]),l+=v.exb[H],b(f,l=UZIP.F._writeLit(L,I,f,l),P-v.df0[L]),l+=v.dxb[L],M+=S}}l=UZIP.F._writeLit(256,C,f,l)}return l},UZIP.F._copyExact=function(e,t,r,i,o){var a=o>>>3;return i[a]=r,i[a+1]=r>>>8,i[a+2]=255-i[a],i[a+3]=255-i[a+1],a+=4,i.set(new Uint8Array(e.buffer,t,r),a),o+(r+4<<3)},UZIP.F.getTrees=function(){for(var e=UZIP.F.U,t=UZIP.F._hufTree(e.lhst,e.ltree,15),r=UZIP.F._hufTree(e.dhst,e.dtree,15),i=[],o=UZIP.F._lenCodes(e.ltree,i),a=[],s=UZIP.F._lenCodes(e.dtree,a),f=0;f4&&0==e.itree[1+(e.ordr[c-1]<<1)];)c--;return[t,r,l,o,s,c,i,a]},UZIP.F.getSecond=function(e){for(var t=[],r=0;r>1)+",");return t},UZIP.F.contSize=function(e,t){for(var r=0,i=0;i15&&(UZIP.F._putsE(r,i,s,f),i+=f)}return i},UZIP.F._lenCodes=function(e,t){for(var r=e.length;2!=r&&0==e[r-1];)r-=2;for(var i=0;i>>1,138))<11?t.push(17,c-3):t.push(18,c-11),i+=2*c-2}else if(o==f&&a==o&&s==o){for(l=i+5;l+2>>1,6);t.push(16,c-3),i+=2*c-2}else t.push(o,0)}return r>>>1},UZIP.F._hufTree=function(e,t,r){var i=[],o=e.length,a=t.length,s=0;for(s=0;sr&&(UZIP.F.restrictDepth(l,r,p),p=r),s=0;st;i++){var s=e[i].d;e[i].d=t,a+=o-(1<>>=r-t;a>0;){(s=e[i].d)=0;i--)e[i].d==t&&a<0&&(e[i].d--,a++);0!=a&&console.log("debt left")},UZIP.F._goodIndex=function(e,t){var r=0;return t[16|r]<=e&&(r|=16),t[8|r]<=e&&(r|=8),t[4|r]<=e&&(r|=4),t[2|r]<=e&&(r|=2),t[1|r]<=e&&(r|=1),r},UZIP.F._writeLit=function(e,t,r,i){return UZIP.F._putsF(r,i,t[e<<1]),i+t[1+(e<<1)]},UZIP.F.inflate=function(e,t){var r=Uint8Array;if(3==e[0]&&0==e[1])return t||new r(0);var i=UZIP.F,o=i._bitsF,a=i._bitsE,s=i._decodeTiny,f=i.makeCodes,l=i.codes2map,c=i._get17,u=i.U,h=null==t;h&&(t=new r(e.length>>>2<<3));for(var d,A,g=0,p=0,m=0,w=0,v=0,b=0,y=0,E=0,F=0;0==g;)if(g=o(e,F,1),p=o(e,F+1,2),F+=3,0!=p){if(h&&(t=UZIP.F._check(t,E+(1<<17))),1==p&&(d=u.flmap,A=u.fdmap,b=511,y=31),2==p){m=a(e,F,5)+257,w=a(e,F+5,5)+1,v=a(e,F+10,4)+4,F+=14;for(var _=0;_<38;_+=2)u.itree[_]=0,u.itree[_+1]=0;var B=1;for(_=0;_B&&(B=U)}F+=3*v,f(u.itree,B),l(u.itree,B,u.imap),d=u.lmap,A=u.dmap,F=s(u.imap,(1<>>4;if(M>>>8==0)t[E++]=M;else{if(256==M)break;var x=E+M-254;if(M>264){var T=u.ldef[M-257];x=E+(T>>>3)+a(e,F,7&T),F+=7&T}var S=A[c(e,F)&y];F+=15&S;var R=S>>>4,O=u.ddef[R],P=(O>>>4)+o(e,F,15&O);for(F+=15&O,h&&(t=UZIP.F._check(t,E+(1<<17)));E>>3),L=e[H-4]|e[H-3]<<8;h&&(t=UZIP.F._check(t,E+L)),t.set(new r(e.buffer,e.byteOffset+H,L),E),F=H+L<<3,E+=L}return t.length==E?t:t.slice(0,E)},UZIP.F._check=function(e,t){var r=e.length;if(t<=r)return e;var i=new Uint8Array(Math.max(r<<1,t));return i.set(e,0),i},UZIP.F._decodeTiny=function(e,t,r,i,o,a){for(var s=UZIP.F._bitsE,f=UZIP.F._get17,l=0;l>>4;if(u<=15)a[l]=u,l++;else{var h=0,d=0;16==u?(d=3+s(i,o,2),o+=2,h=a[l-1]):17==u?(d=3+s(i,o,3),o+=3):18==u&&(d=11+s(i,o,7),o+=7);for(var A=l+d;l>>1;ao&&(o=f),a++}for(;a>1,f=e[a+1],l=s<<4|f,c=t-f,u=e[a]<>>15-t]=l,u++}},UZIP.F.revCodes=function(e,t){for(var r=UZIP.F.U.rev15,i=15-t,o=0;o>>i}},UZIP.F._putsE=function(e,t,r){r<<=7&t;var i=t>>>3;e[i]|=r,e[i+1]|=r>>>8},UZIP.F._putsF=function(e,t,r){r<<=7&t;var i=t>>>3;e[i]|=r,e[i+1]|=r>>>8,e[i+2]|=r>>>16},UZIP.F._bitsE=function(e,t,r){return(e[t>>>3]|e[1+(t>>>3)]<<8)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)},UZIP.F._get25=function(e,t){return(e[t>>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16|e[3+(t>>>3)]<<24)>>>(7&t)},UZIP.F.U=(t=Uint16Array,r=Uint32Array,{next_code:new t(16),bl_count:new t(16),ordr:[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],of0:[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,999,999,999],exb:[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0],ldef:new t(32),df0:[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,65535,65535],dxb:[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0],ddef:new r(32),flmap:new t(512),fltree:[],fdmap:new t(32),fdtree:[],lmap:new t(32768),ltree:[],ttree:[],dmap:new t(32768),dtree:[],imap:new t(512),itree:[],rev15:new t(32768),lhst:new r(286),dhst:new r(30),ihst:new r(19),lits:new r(15e3),strt:new t(65536),prev:new t(32768)}),function(){for(var e=UZIP.F.U,t=0;t<32768;t++){var r=t;r=(4278255360&(r=(4042322160&(r=(3435973836&(r=(2863311530&r)>>>1|(1431655765&r)<<1))>>>2|(858993459&r)<<2))>>>4|(252645135&r)<<4))>>>8|(16711935&r)<<8,e.rev15[t]=(r>>>16|r<<16)>>>17}function pushV(e,t,r){for(;0!=t--;)e.push(0,r)}for(t=0;t<32;t++)e.ldef[t]=e.of0[t]<<3|e.exb[t],e.ddef[t]=e.df0[t]<<4|e.dxb[t];pushV(e.fltree,144,8),pushV(e.fltree,112,9),pushV(e.fltree,24,7),pushV(e.fltree,8,8),UZIP.F.makeCodes(e.fltree,9),UZIP.F.codes2map(e.fltree,9,e.flmap),UZIP.F.revCodes(e.fltree,9),pushV(e.fdtree,32,5),UZIP.F.makeCodes(e.fdtree,5),UZIP.F.codes2map(e.fdtree,5,e.fdmap),UZIP.F.revCodes(e.fdtree,5),pushV(e.itree,19,0),pushV(e.ltree,286,0),pushV(e.dtree,30,0),pushV(e.ttree,320,0)}()}({get exports(){return e},set exports(t){e=t}});var UZIP=_mergeNamespaces({__proto__:null,default:e},[e]);const UPNG=function(){var e={nextZero(e,t){for(;0!=e[t];)t++;return t},readUshort:(e,t)=>e[t]<<8|e[t+1],writeUshort(e,t,r){e[t]=r>>8&255,e[t+1]=255&r},readUint:(e,t)=>16777216*e[t]+(e[t+1]<<16|e[t+2]<<8|e[t+3]),writeUint(e,t,r){e[t]=r>>24&255,e[t+1]=r>>16&255,e[t+2]=r>>8&255,e[t+3]=255&r},readASCII(e,t,r){let i="";for(let o=0;oe.length<2?`0${e}`:e,readUTF8(t,r,i){let o,a="";for(let o=0;o>3)]>>7-((7&A)<<0)&1);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>2)]>>6-((3&A)<<1)&3);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>1)]>>4-((1&A)<<2)&15);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>>3)]>>>7-(7&B)&1))==255*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(2==h)for(B=0;B>>2)]>>>6-((3&B)<<1)&3))==85*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(4==h)for(B=0;B>>1)]>>>4-((1&B)<<2)&15))==17*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(8==h)for(B=0;B>3,s=Math.ceil(r*o/8),f=new Uint8Array(i*s);let l=0;const c=[0,0,4,0,2,0,1],u=[0,4,0,2,0,1,0],h=[8,8,8,4,4,2,2],d=[8,8,4,4,2,2,1];let A=0;for(;A<7;){const p=h[A],m=d[A];let w=0,v=0,b=c[A];for(;b>3])>>7-(7&i)&1,f[_*s+(t>>3)]|=g<<7-((7&t)<<0);if(2==o)g=(g=e[i>>3])>>6-(7&i)&3,f[_*s+(t>>2)]|=g<<6-((3&t)<<1);if(4==o)g=(g=e[i>>3])>>4-(7&i)&15,f[_*s+(t>>1)]|=g<<4-((1&t)<<2);if(o>=8){const r=_*s+t*a;for(let t=0;t>3)+t]}i+=o,t+=m}F++,_+=p}w*v!=0&&(l+=v*(1+E)),A+=1}return f}(r,e)),r}function _inflate(e,r){return t(new Uint8Array(e.buffer,2,e.length-6),r)}var t=function(){const e={H:{}};return e.H.N=function(t,r){const i=Uint8Array;let o,a,s=0,f=0,l=0,c=0,u=0,h=0,d=0,A=0,g=0;if(3==t[0]&&0==t[1])return r||new i(0);const p=e.H,m=p.b,w=p.e,v=p.R,b=p.n,y=p.A,E=p.Z,F=p.m,_=null==r;for(_&&(r=new i(t.length>>>2<<5));0==s;)if(s=m(t,g,1),f=m(t,g+1,2),g+=3,0!=f){if(_&&(r=e.H.W(r,A+(1<<17))),1==f&&(o=F.J,a=F.h,h=511,d=31),2==f){l=w(t,g,5)+257,c=w(t,g+5,5)+1,u=w(t,g+10,4)+4,g+=14;let e=1;for(var B=0;B<38;B+=2)F.Q[B]=0,F.Q[B+1]=0;for(B=0;Be&&(e=r)}g+=3*u,b(F.Q,e),y(F.Q,e,F.u),o=F.w,a=F.d,g=v(F.u,(1<>>4;if(i>>>8==0)r[A++]=i;else{if(256==i)break;{let e=A+i-254;if(i>264){const r=F.q[i-257];e=A+(r>>>3)+w(t,g,7&r),g+=7&r}const o=a[E(t,g)&d];g+=15&o;const s=o>>>4,f=F.c[s],l=(f>>>4)+m(t,g,15&f);for(g+=15&f;A>>3),a=t[o-4]|t[o-3]<<8;_&&(r=e.H.W(r,A+a)),r.set(new i(t.buffer,t.byteOffset+o,a),A),g=o+a<<3,A+=a}return r.length==A?r:r.slice(0,A)},e.H.W=function(e,t){const r=e.length;if(t<=r)return e;const i=new Uint8Array(r<<1);return i.set(e,0),i},e.H.R=function(t,r,i,o,a,s){const f=e.H.e,l=e.H.Z;let c=0;for(;c>>4;if(i<=15)s[c]=i,c++;else{let e=0,t=0;16==i?(t=3+f(o,a,2),a+=2,e=s[c-1]):17==i?(t=3+f(o,a,3),a+=3):18==i&&(t=11+f(o,a,7),a+=7);const r=c+t;for(;c>>1;for(;ao&&(o=r),a++}for(;a>1,s=t[e+1],f=o<<4|s,l=r-s;let c=t[e]<>>15-r]=f,c++}}},e.H.l=function(t,r){const i=e.H.m.r,o=15-r;for(let e=0;e>>o}},e.H.M=function(e,t,r){r<<=7&t;const i=t>>>3;e[i]|=r,e[i+1]|=r>>>8},e.H.I=function(e,t,r){r<<=7&t;const i=t>>>3;e[i]|=r,e[i+1]|=r>>>8,e[i+2]|=r>>>16},e.H.e=function(e,t,r){return(e[t>>>3]|e[1+(t>>>3)]<<8)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)},e.H.i=function(e,t){return(e[t>>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16|e[3+(t>>>3)]<<24)>>>(7&t)},e.H.m=function(){const e=Uint16Array,t=Uint32Array;return{K:new e(16),j:new e(16),X:[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],S:[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,999,999,999],T:[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0],q:new e(32),p:[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,65535,65535],z:[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0],c:new t(32),J:new e(512),_:[],h:new e(32),$:[],w:new e(32768),C:[],v:[],d:new e(32768),D:[],u:new e(512),Q:[],r:new e(32768),s:new t(286),Y:new t(30),a:new t(19),t:new t(15e3),k:new e(65536),g:new e(32768)}}(),function(){const t=e.H.m;for(var r=0;r<32768;r++){let e=r;e=(2863311530&e)>>>1|(1431655765&e)<<1,e=(3435973836&e)>>>2|(858993459&e)<<2,e=(4042322160&e)>>>4|(252645135&e)<<4,e=(4278255360&e)>>>8|(16711935&e)<<8,t.r[r]=(e>>>16|e<<16)>>>17}function n(e,t,r){for(;0!=t--;)e.push(0,r)}for(r=0;r<32;r++)t.q[r]=t.S[r]<<3|t.T[r],t.c[r]=t.p[r]<<4|t.z[r];n(t._,144,8),n(t._,112,9),n(t._,24,7),n(t._,8,8),e.H.n(t._,9),e.H.A(t._,9,t.J),e.H.l(t._,9),n(t.$,32,5),e.H.n(t.$,5),e.H.A(t.$,5,t.h),e.H.l(t.$,5),n(t.Q,19,0),n(t.C,286,0),n(t.D,30,0),n(t.v,320,0)}(),e.H.N}();function _getBPP(e){return[1,null,3,1,2,null,4][e.ctype]*e.depth}function _filterZero(e,t,r,i,o){let a=_getBPP(t);const s=Math.ceil(i*a/8);let f,l;a=Math.ceil(a/8);let c=e[r],u=0;if(c>1&&(e[r]=[0,0,1][c-2]),3==c)for(u=a;u>>1)&255;for(let t=0;t>>1);for(;u>>1)}else{for(;u=0&&f>=0?(h=r*t+a<<2,d=(f+r)*o+s+a<<2):(h=(-f+r)*t-s+a<<2,d=r*o+a<<2),0==l)i[d]=e[h],i[d+1]=e[h+1],i[d+2]=e[h+2],i[d+3]=e[h+3];else if(1==l){var A=e[h+3]*(1/255),g=e[h]*A,p=e[h+1]*A,m=e[h+2]*A,w=i[d+3]*(1/255),v=i[d]*w,b=i[d+1]*w,y=i[d+2]*w;const t=1-A,r=A+w*t,o=0==r?0:1/r;i[d+3]=255*r,i[d+0]=(g+v*t)*o,i[d+1]=(p+b*t)*o,i[d+2]=(m+y*t)*o}else if(2==l){A=e[h+3],g=e[h],p=e[h+1],m=e[h+2],w=i[d+3],v=i[d],b=i[d+1],y=i[d+2];A==w&&g==v&&p==b&&m==y?(i[d]=0,i[d+1]=0,i[d+2]=0,i[d+3]=0):(i[d]=g,i[d+1]=p,i[d+2]=m,i[d+3]=A)}else if(3==l){A=e[h+3],g=e[h],p=e[h+1],m=e[h+2],w=i[d+3],v=i[d],b=i[d+1],y=i[d+2];if(A==w&&g==v&&p==b&&m==y)continue;if(A<220&&w>20)return!1}return!0}return{decode:function decode(r){const i=new Uint8Array(r);let o=8;const a=e,s=a.readUshort,f=a.readUint,l={tabs:{},frames:[]},c=new Uint8Array(i.length);let u,h=0,d=0;const A=[137,80,78,71,13,10,26,10];for(var g=0;g<8;g++)if(i[g]!=A[g])throw"The input is not a PNG file!";for(;o>>1:r>>>=1;e[t]=r}return e}(),update(e,t,r,o){for(let a=0;a>>8;return e},crc:(e,t,r)=>4294967295^i.update(4294967295,e,t,r)};function addErr(e,t,r,i){t[r]+=e[0]*i>>4,t[r+1]+=e[1]*i>>4,t[r+2]+=e[2]*i>>4,t[r+3]+=e[3]*i>>4}function N(e){return Math.max(0,Math.min(255,e))}function D(e,t){const r=e[0]-t[0],i=e[1]-t[1],o=e[2]-t[2],a=e[3]-t[3];return r*r+i*i+o*o+a*a}function dither(e,t,r,i,o,a,s){null==s&&(s=1);const f=i.length,l=[];for(var c=0;c>>0&255,e>>>8&255,e>>>16&255,e>>>24&255])}for(c=0;c>2]=u,A[c>>2]=i[u]}}function _main(e,r,o,a,s){null==s&&(s={});const{crc:f}=i,l=t.writeUint,c=t.writeUshort,u=t.writeASCII;let h=8;const d=e.frames.length>1;let A,g=!1,p=33+(d?20:0);if(null!=s.sRGB&&(p+=13),null!=s.pHYs&&(p+=21),null!=s.iCCP&&(A=pako.deflate(s.iCCP),p+=21+A.length+4),3==e.ctype){for(var m=e.plte.length,w=0;w>>24!=255&&(g=!0);p+=8+3*m+4+(g?8+1*m+4:0)}for(var v=0;v>>8&255,a=r>>>16&255;b[h+t+0]=i,b[h+t+1]=o,b[h+t+2]=a}if(h+=3*m,l(b,h,f(b,h-3*m-4,3*m+4)),h+=4,g){l(b,h,m),h+=4,u(b,h,"tRNS"),h+=4;for(w=0;w>>24&255;h+=m,l(b,h,f(b,h-m-4,m+4)),h+=4}}let E=0;for(v=0;vc&&(c=t),eh&&(h=e))}-1==c&&(s=f=c=h=0),a&&(1==(1&s)&&s--,1==(1&f)&&f--);const v=(c-s+1)*(h-f+1);v>2,e>>2);F.push(_);const t=new Uint8Array(r.abuf,i,e);h&&dither(B.img,B.rect.width,B.rect.height,E,t,_),B.img.set(t),i+=e}}else for(p=0;pU&&t==e[w-U])_[w]=_[w-U];else{let e=y[t];if(null==e&&(y[t]=e=E.length,E.push(t),E.length>=300))break;_[w]=e}}}const C=E.length;C<=256&&0==u&&(A=C<=2?1:C<=4?2:C<=16?4:8,A=Math.max(A,c));for(p=0;p>1)]|=o[e+Q]<<4-4*(1&Q);else if(2==A)for(Q=0;Q>2)]|=o[e+Q]<<6-2*(3&Q);else if(1==A)for(Q=0;Q>3)]|=o[e+Q]<<7-1*(7&Q)}t=I,d=3,i=1}else if(0==v&&1==b.length){I=new Uint8Array(U*e*3);const o=U*e;for(w=0;ww&&(w=i),fv&&(v=f))}-1==w&&(p=m=w=v=0),f&&(1==(1&p)&&p--,1==(1&m)&&m--),s={x:p,y:m,width:w-p+1,height:v-m+1};const b=o[a];b.rect=s,b.blend=1,b.img=new Uint8Array(s.width*s.height*4),0==o[a-1].dispose?(e(u,r,i,b.img,s.width,s.height,-s.x,-s.y,0),_prepareDiff(A,r,i,b.img,s)):e(A,r,i,b.img,s.width,s.height,-s.x,-s.y,0)}function _prepareDiff(t,r,i,o,a){e(t,r,i,o,a.width,a.height,-a.x,-a.y,2)}function _filterZero(e,t,r,i,o,a,s){const f=[];let l,c=[0,1,2,3,4];-1!=a?c=[a]:(t*i>5e5||1==r)&&(c=[0]),s&&(l={level:0});const u=UZIP;for(var h=0;h>1)+256&255;if(4==s)for(c=a;c>1)&255;for(c=a;c>1)&255}if(4==s){for(c=0;c>2);let u;if(r.length<2e7)for(var h=0;h>2]=u.ind,o[h>>2]=u.est.rgba}else for(h=0;h>2]=u.ind,o[h>>2]=u.est.rgba}return{abuf:i.buffer,inds:c,plte:f}}function getKDtree(e,t,r){null==r&&(r=1e-4);const i=new Uint32Array(e.buffer),o={i0:0,i1:e.length,bst:null,est:null,tdst:0,left:null,right:null};o.bst=stats(e,o.i0,o.i1),o.est=estats(o.bst);const a=[o];for(;a.lengtht&&(t=a[s].est.L,o=s);if(t=l||f.i1<=l){f.est.L=0;continue}const c={i0:f.i0,i1:l,bst:null,est:null,tdst:0,left:null,right:null};c.bst=stats(e,c.i0,c.i1),c.est=estats(c.bst);const u={i0:l,i1:f.i1,bst:null,est:null,tdst:0,left:null,right:null};u.bst={R:[],m:[],N:f.bst.N-c.bst.N};for(s=0;s<16;s++)u.bst.R[s]=f.bst.R[s]-c.bst.R[s];for(s=0;s<4;s++)u.bst.m[s]=f.bst.m[s]-c.bst.m[s];u.est=estats(u.bst),f.left=c,f.right=u,a[o]=c,a.push(u)}a.sort(((e,t)=>t.bst.N-e.bst.N));for(s=0;s0&&(s=e.right,f=e.left);const l=getNearest(s,t,r,i,o);if(l.tdst<=a*a)return l;const c=getNearest(f,t,r,i,o);return c.tdsta;)i-=4;if(r>=i)break;const s=t[r>>2];t[r>>2]=t[i>>2],t[i>>2]=s,r+=4,i-=4}for(;vecDot(e,r,o)>a;)r-=4;return r+4}function vecDot(e,t,r){return e[t]*r[0]+e[t+1]*r[1]+e[t+2]*r[2]+e[t+3]*r[3]}function stats(e,t,r){const i=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],o=[0,0,0,0],a=r-t>>2;for(let a=t;a>>0}}var o={multVec:(e,t)=>[e[0]*t[0]+e[1]*t[1]+e[2]*t[2]+e[3]*t[3],e[4]*t[0]+e[5]*t[1]+e[6]*t[2]+e[7]*t[3],e[8]*t[0]+e[9]*t[1]+e[10]*t[2]+e[11]*t[3],e[12]*t[0]+e[13]*t[1]+e[14]*t[2]+e[15]*t[3]],dot:(e,t)=>e[0]*t[0]+e[1]*t[1]+e[2]*t[2]+e[3]*t[3],sml:(e,t)=>[e*t[0],e*t[1],e*t[2],e*t[3]]};UPNG.encode=function encode(e,t,r,i,o,a,s){null==i&&(i=0),null==s&&(s=!1);const f=compress(e,t,r,i,[!1,!1,!1,0,s,!1]);return compressPNG(f,-1),_main(f,t,r,o,a)},UPNG.encodeLL=function encodeLL(e,t,r,i,o,a,s,f){const l={ctype:0+(1==i?0:2)+(0==o?0:4),depth:a,frames:[]},c=(i+o)*a,u=c*t;for(let i=0;i>>0),set16(1),set16(32),set32(3),set32(c),set32(2835),set32(2835),seek(8),set32(16711680),set32(65280),set32(255),set32(4278190080),set32(1466527264),function convert(){for(;b0;){for(w=122+b*l,g=0;g>>24,d.setUint32(w+g,p<<8|m),g+=4;b++}E{t(new Blob([e],{type:"image/bmp"}))}))},_dly:9};var r={CHROME:"CHROME",FIREFOX:"FIREFOX",DESKTOP_SAFARI:"DESKTOP_SAFARI",IE:"IE",IOS:"IOS",ETC:"ETC"},i={[r.CHROME]:16384,[r.FIREFOX]:11180,[r.DESKTOP_SAFARI]:16384,[r.IE]:8192,[r.IOS]:4096,[r.ETC]:8192};const o="undefined"!=typeof window,a="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope,s=o&&window.cordova&&window.cordova.require&&window.cordova.require("cordova/modulemapper"),CustomFile=(o||a)&&(s&&s.getOriginalSymbol(window,"File")||"undefined"!=typeof File&&File),CustomFileReader=(o||a)&&(s&&s.getOriginalSymbol(window,"FileReader")||"undefined"!=typeof FileReader&&FileReader);function getFilefromDataUrl(e,t,r=Date.now()){return new Promise((i=>{const o=e.split(","),a=o[0].match(/:(.*?);/)[1],s=globalThis.atob(o[1]);let f=s.length;const l=new Uint8Array(f);for(;f--;)l[f]=s.charCodeAt(f);const c=new Blob([l],{type:a});c.name=t,c.lastModified=r,i(c)}))}function getDataUrlFromFile(e){return new Promise(((t,r)=>{const i=new CustomFileReader;i.onload=()=>t(i.result),i.onerror=e=>r(e),i.readAsDataURL(e)}))}function loadImage(e){return new Promise(((t,r)=>{const i=new Image;i.onload=()=>t(i),i.onerror=e=>r(e),i.src=e}))}function getBrowserName(){if(void 0!==getBrowserName.cachedResult)return getBrowserName.cachedResult;let e=r.ETC;const{userAgent:t}=navigator;return/Chrom(e|ium)/i.test(t)?e=r.CHROME:/iP(ad|od|hone)/i.test(t)&&/WebKit/i.test(t)?e=r.IOS:/Safari/i.test(t)?e=r.DESKTOP_SAFARI:/Firefox/i.test(t)?e=r.FIREFOX:(/MSIE/i.test(t)||!0==!!document.documentMode)&&(e=r.IE),getBrowserName.cachedResult=e,getBrowserName.cachedResult}function approximateBelowMaximumCanvasSizeOfBrowser(e,t){const r=getBrowserName(),o=i[r];let a=e,s=t,f=a*s;const l=a>s?s/a:a/s;for(;f>o*o;){const e=(o+a)/2,t=(o+s)/2;et.toBlob(e,r))).then(function(e){try{return l=e,l.name=i,l.lastModified=o,$If_5.call(this)}catch(e){return f(e)}}.bind(this),f);{if("function"==typeof OffscreenCanvas&&e instanceof OffscreenCanvas)return e.convertToBlob({type:r,quality:a}).then(function(e){try{return l=e,l.name=i,l.lastModified=o,$If_6.call(this)}catch(e){return f(e)}}.bind(this),f);{let d;return d=e.toDataURL(r,a),getFilefromDataUrl(d,i,o).then(function(e){try{return l=e,$If_6.call(this)}catch(e){return f(e)}}.bind(this),f)}function $If_6(){return $If_5.call(this)}}function $If_5(){return $If_4.call(this)}}function $If_4(){return s(l)}}))}function cleanupCanvasMemory(e){e.width=0,e.height=0}function isAutoOrientationInBrowser(){return new Promise((function(e,t){let r,i,o,a,s;return void 0!==isAutoOrientationInBrowser.cachedResult?e(isAutoOrientationInBrowser.cachedResult):(r="data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/xABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==",getFilefromDataUrl("data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/xABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==","test.jpg",Date.now()).then((function(r){try{return i=r,drawFileInCanvas(i).then((function(r){try{return o=r[1],canvasToFile(o,i.type,i.name,i.lastModified).then((function(r){try{return a=r,cleanupCanvasMemory(o),drawFileInCanvas(a).then((function(r){try{return s=r[0],isAutoOrientationInBrowser.cachedResult=1===s.width&&2===s.height,e(isAutoOrientationInBrowser.cachedResult)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t))}))}function getExifOrientation(e){return new Promise(((t,r)=>{const i=new CustomFileReader;i.onload=e=>{const r=new DataView(e.target.result);if(65496!=r.getUint16(0,!1))return t(-2);const i=r.byteLength;let o=2;for(;or(e),i.readAsArrayBuffer(e)}))}function handleMaxWidthOrHeight(e,t){const{width:r}=e,{height:i}=e,{maxWidthOrHeight:o}=t;let a,s=e;return isFinite(o)&&(r>o||i>o)&&([s,a]=getNewCanvasAndCtx(r,i),r>i?(s.width=o,s.height=i/r*o):(s.width=r/i*o,s.height=o),a.drawImage(e,0,0,s.width,s.height),cleanupCanvasMemory(e)),s}function followExifOrientation(e,t){const{width:r}=e,{height:i}=e,[o,a]=getNewCanvasAndCtx(r,i);switch(t>4&&t<9?(o.width=i,o.height=r):(o.width=r,o.height=i),t){case 2:a.transform(-1,0,0,1,r,0);break;case 3:a.transform(-1,0,0,-1,r,i);break;case 4:a.transform(1,0,0,-1,0,i);break;case 5:a.transform(0,1,1,0,0,0);break;case 6:a.transform(0,1,-1,0,i,0);break;case 7:a.transform(0,-1,-1,0,i,r);break;case 8:a.transform(0,-1,1,0,0,r)}return a.drawImage(e,0,0,r,i),cleanupCanvasMemory(e),o}function compress(e,t,r=0){return new Promise((function(i,o){let a,s,f,l,c,u,h,d,A,g,p,m,w,v,b,y,E,F,_,B;function incProgress(e=5){if(t.signal&&t.signal.aborted)throw t.signal.reason;a+=e,t.onProgress(Math.min(a,100))}function setProgress(e){if(t.signal&&t.signal.aborted)throw t.signal.reason;a=Math.min(Math.max(e,a),100),t.onProgress(a)}return a=r,s=t.maxIteration||10,f=1024*t.maxSizeMB*1024,incProgress(),drawFileInCanvas(e,t).then(function(r){try{return[,l]=r,incProgress(),c=handleMaxWidthOrHeight(l,t),incProgress(),new Promise((function(r,i){var o;if(!(o=t.exifOrientation))return getExifOrientation(e).then(function(e){try{return o=e,$If_2.call(this)}catch(e){return i(e)}}.bind(this),i);function $If_2(){return r(o)}return $If_2.call(this)})).then(function(r){try{return u=r,incProgress(),isAutoOrientationInBrowser().then(function(r){try{return h=r?c:followExifOrientation(c,u),incProgress(),d=t.initialQuality||1,A=t.fileType||e.type,canvasToFile(h,A,e.name,e.lastModified,d).then(function(r){try{{if(g=r,incProgress(),p=g.size>f,m=g.size>e.size,!p&&!m)return setProgress(100),i(g);var a;function $Loop_3(){if(s--&&(b>f||b>w)){let t,r;return t=B?.95*_.width:_.width,r=B?.95*_.height:_.height,[E,F]=getNewCanvasAndCtx(t,r),F.drawImage(_,0,0,t,r),d*="image/png"===A?.85:.95,canvasToFile(E,A,e.name,e.lastModified,d).then((function(e){try{return y=e,cleanupCanvasMemory(_),_=E,b=y.size,setProgress(Math.min(99,Math.floor((v-b)/(v-f)*100))),$Loop_3}catch(e){return o(e)}}),o)}return[1]}return w=e.size,v=g.size,b=v,_=h,B=!t.alwaysKeepResolution&&p,(a=function(e){for(;e;){if(e.then)return void e.then(a,o);try{if(e.pop){if(e.length)return e.pop()?$Loop_3_exit.call(this):e;e=$Loop_3}else e=e.call(this)}catch(e){return o(e)}}}.bind(this))($Loop_3);function $Loop_3_exit(){return cleanupCanvasMemory(_),cleanupCanvasMemory(E),cleanupCanvasMemory(c),cleanupCanvasMemory(h),cleanupCanvasMemory(l),setProgress(100),i(y)}}}catch(u){return o(u)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}))}const f="\nlet scriptImported = false\nself.addEventListener('message', async (e) => {\n const { file, id, imageCompressionLibUrl, options } = e.data\n options.onProgress = (progress) => self.postMessage({ progress, id })\n try {\n if (!scriptImported) {\n // console.log('[worker] importScripts', imageCompressionLibUrl)\n self.importScripts(imageCompressionLibUrl)\n scriptImported = true\n }\n // console.log('[worker] self', self)\n const compressedFile = await imageCompression(file, options)\n self.postMessage({ file: compressedFile, id })\n } catch (e) {\n // console.error('[worker] error', e)\n self.postMessage({ error: e.message + '\\n' + e.stack, id })\n }\n})\n";let l;function compressOnWebWorker(e,t){return new Promise(((r,i)=>{l||(l=function createWorkerScriptURL(e){const t=[];return"function"==typeof e?t.push(`(${e})()`):t.push(e),URL.createObjectURL(new Blob(t))}(f));const o=new Worker(l);o.addEventListener("message",(function handler(e){if(t.signal&&t.signal.aborted)o.terminate();else if(void 0===e.data.progress){if(e.data.error)return i(new Error(e.data.error)),void o.terminate();r(e.data.file),o.terminate()}else t.onProgress(e.data.progress)})),o.addEventListener("error",i),t.signal&&t.signal.addEventListener("abort",(()=>{i(t.signal.reason),o.terminate()})),o.postMessage({file:e,imageCompressionLibUrl:t.libURL,options:{...t,onProgress:void 0,signal:void 0}})}))}function imageCompression(e,t){return new Promise((function(r,i){let o,a,s,f,l,c;if(o={...t},s=0,({onProgress:f}=o),o.maxSizeMB=o.maxSizeMB||Number.POSITIVE_INFINITY,l="boolean"!=typeof o.useWebWorker||o.useWebWorker,delete o.useWebWorker,o.onProgress=e=>{s=e,"function"==typeof f&&f(s)},!(e instanceof Blob||e instanceof CustomFile))return i(new Error("The file given is not an instance of Blob or File"));if(!/^image/.test(e.type))return i(new Error("The file given is not an image"));if(c="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope,!l||"function"!=typeof Worker||c)return compress(e,o).then(function(e){try{return a=e,$If_4.call(this)}catch(e){return i(e)}}.bind(this),i);var u=function(){try{return $If_4.call(this)}catch(e){return i(e)}}.bind(this),$Try_1_Catch=function(t){try{return compress(e,o).then((function(e){try{return a=e,u()}catch(e){return i(e)}}),i)}catch(e){return i(e)}};try{return o.libURL=o.libURL||"https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.js",compressOnWebWorker(e,o).then((function(e){try{return a=e,u()}catch(e){return $Try_1_Catch()}}),$Try_1_Catch)}catch(e){$Try_1_Catch()}function $If_4(){try{a.name=e.name,a.lastModified=e.lastModified}catch(e){}try{o.preserveExif&&"image/jpeg"===e.type&&(!o.fileType||o.fileType&&o.fileType===e.type)&&(a=copyExifWithoutOrientation(e,a))}catch(e){}return r(a)}}))}return imageCompression.getDataUrlFromFile=getDataUrlFromFile,imageCompression.getFilefromDataUrl=getFilefromDataUrl,imageCompression.loadImage=loadImage,imageCompression.drawImageInCanvas=drawImageInCanvas,imageCompression.drawFileInCanvas=drawFileInCanvas,imageCompression.canvasToFile=canvasToFile,imageCompression.getExifOrientation=getExifOrientation,imageCompression.handleMaxWidthOrHeight=handleMaxWidthOrHeight,imageCompression.followExifOrientation=followExifOrientation,imageCompression.cleanupCanvasMemory=cleanupCanvasMemory,imageCompression.isAutoOrientationInBrowser=isAutoOrientationInBrowser,imageCompression.approximateBelowMaximumCanvasSizeOfBrowser=approximateBelowMaximumCanvasSizeOfBrowser,imageCompression.copyExifWithoutOrientation=copyExifWithoutOrientation,imageCompression.getBrowserName=getBrowserName,imageCompression.version="2.0.2",imageCompression})); +//# sourceMappingURL=browser-image-compression.js.map diff --git a/public/pku_icon.png b/public/pku_icon.png new file mode 100644 index 0000000..6ada1ba Binary files /dev/null and b/public/pku_icon.png differ diff --git a/public/pku_logo.png b/public/pku_logo.png new file mode 100644 index 0000000..d659726 Binary files /dev/null and b/public/pku_logo.png differ diff --git a/public/pku_logo2.png b/public/pku_logo2.png new file mode 100644 index 0000000..6654cf2 Binary files /dev/null and b/public/pku_logo2.png differ diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..26e4b13 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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 + +export default function LoginPage() { + const router = useRouter() + + const form = useForm({ + 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 ( +
+ + + 登录 + + +
+ + ( + + 用户ID + + + + + + )} + /> + ( + + 密码 + + + + + + )} + /> + {form.formState.errors.root && ( +
+ {form.formState.errors.root.message} +
+ )} + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/(main)/[...notFound]/page.tsx b/src/app/(main)/[...notFound]/page.tsx new file mode 100644 index 0000000..e5b9c40 --- /dev/null +++ b/src/app/(main)/[...notFound]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function NotFoundCatchAll() { + notFound() +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/layout.dev.tsx b/src/app/(main)/dev/arch/layout.dev.tsx new file mode 100644 index 0000000..640f326 --- /dev/null +++ b/src/app/(main)/dev/arch/layout.dev.tsx @@ -0,0 +1,9 @@ +import { SubMenuLayout } from "@/components/layout/sub-menu-layout"; + +export default function ArchLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/package/components/PackageAnalyzeDialog.tsx b/src/app/(main)/dev/arch/package/components/PackageAnalyzeDialog.tsx new file mode 100644 index 0000000..a8a448e --- /dev/null +++ b/src/app/(main)/dev/arch/package/components/PackageAnalyzeDialog.tsx @@ -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 ( + + ) +} + +/** + * 依赖包分析进度对话框 + */ +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 ( +
+ {/* 进度统计 */} +
+ {progress.totalPackages !== undefined && ( +
+ 依赖包总数: + {progress.totalPackages} +
+ )} + {progress.analyzedPackages !== undefined && ( +
+ 已分析: + {progress.analyzedPackages} +
+ )} + {successCount > 0 && ( +
+ 成功: + {successCount} +
+ )} + {progress.failedPackages !== undefined && progress.failedPackages > 0 && ( +
+ 失败: + {progress.failedPackages} +
+ )} + {progress.skippedPackages !== undefined && progress.skippedPackages > 0 && ( +
+ 跳过: + {progress.skippedPackages} +
+ )} +
+ + {/* 当前处理的依赖包 */} + {progress.currentPackage && progress.state === 'active' && ( +
+
当前依赖包:
+
{progress.currentPackage}
+
+ )} + + {/* 最近的错误信息 */} + {progress.recentErrors && progress.recentErrors.length > 0 && ( +
+
最近的错误 (最多显示10条):
+
+ {progress.recentErrors.map((err, index) => ( +
+
{err.packageName}
+
{err.error}
+ {index < progress.recentErrors!.length - 1 && ( +
+ )} +
+ ))} +
+
+ )} +
+ ) + } + + return ( + + 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} + /> + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/package/components/PackageDetailSheet.tsx b/src/app/(main)/dev/arch/package/components/PackageDetailSheet.tsx new file mode 100644 index 0000000..5126682 --- /dev/null +++ b/src/app/(main)/dev/arch/package/components/PackageDetailSheet.tsx @@ -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 ( + + + + v{pkg.version} + + + {pkg.pkgType.name} + + + } + icon={} + /> + + } + description={{pkg.description}} + > + {/* 基本信息 */} + + + } + /> + + v{pkg.version} + + } + /> + + {pkg.pkgType.name} +
+ } + /> + + + + + + + {/* 官方描述 */} + +
+ {pkg.description} +
+
+ + {/* 项目中的角色 */} + +
+ + {pkg.projectRoleSummary} +
+ } + /> + +
+ + {/* 主要使用模式 */} + {usagePatterns.length > 0 && ( + + + + )} + + {/* 关联文件 */} + {relatedFileItems.length > 0 && ( + + + + )} + + {/* 链接信息 */} + + + {pkg.homepage && ( + + + {pkg.homepage} + + } + /> + )} + {pkg.repositoryUrl && ( + + + {pkg.repositoryUrl} + + } + /> + )} + + + + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/package/page.dev.tsx b/src/app/(main)/dev/arch/package/page.dev.tsx new file mode 100644 index 0000000..a49b82e --- /dev/null +++ b/src/app/(main)/dev/arch/package/page.dev.tsx @@ -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 ( + + +
+ + + {pkg.homepage ? ( + + {pkg.name} + + ) : ( + + {pkg.name} + + )} + {pkg.repositoryUrl && ( + + + + )} + + + + + v{pkg.version} + + + +
+
{pkg.name} v{pkg.version}
+
更新于 {formatDate(pkg.modifiedAt)}
+
+
+
+
+ + {pkg.description} + +
+ +
+
+ + 核心功能 +
+

{pkg.projectRoleSummary}

+
+
+
+ + {pkg.relatedFileCount} + 个文件 +
+ + +
+ + {formatDate(pkg.lastAnalyzedAt)} +
+
+ +
最近分析时间
+
+
+
+
+
+ ); +} + +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(''); + + // 分析对话框状态 + const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false) + const [analyzeJobId, setAnalyzeJobId] = useState(null) + + // 详情Sheet状态 + const [selectedPackage, setSelectedPackage] = useState(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 ( +
+ +

暂无依赖包数据

+
+ ); + } + + return ( + <> + {isLoading ? ( + + +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ) : ( + + {tabItems.map((item) => { + const filteredPackages = getFilteredPackages(item.id); + + return ( + +
+ {/* 搜索栏和操作按钮 */} +
+ +
+ +
+ + {/* 包列表 */} + {filteredPackages.length > 0 ? ( +
+ {filteredPackages.map((pkg) => ( + handleCardClick(pkg)} + /> + ))} +
+ ) : ( +
+ +

+ {searchQuery ? '未找到匹配的依赖包' : '暂无依赖包数据'} +

+
+ )} +
+ + ); + })} + + )} + + {/* 依赖包分析进度对话框 */} + + + {/* 依赖包详情Sheet */} + + + ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/page.dev.tsx b/src/app/(main)/dev/arch/page.dev.tsx new file mode 100644 index 0000000..f5a6cbc --- /dev/null +++ b/src/app/(main)/dev/arch/page.dev.tsx @@ -0,0 +1,5 @@ +import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect"; + +export default function ArchPage() { + return ; +} \ No newline at end of file diff --git a/src/app/(main)/dev/dev-theme.css b/src/app/(main)/dev/dev-theme.css new file mode 100644 index 0000000..768a0ac --- /dev/null +++ b/src/app/(main)/dev/dev-theme.css @@ -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); +} diff --git a/src/app/(main)/dev/file/columns.tsx b/src/app/(main)/dev/file/columns.tsx new file mode 100644 index 0000000..037f3f7 --- /dev/null +++ b/src/app/(main)/dev/file/columns.tsx @@ -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
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/components/FileDetailSheet.tsx b/src/app/(main)/dev/file/components/FileDetailSheet.tsx new file mode 100644 index 0000000..a38d5bd --- /dev/null +++ b/src/app/(main)/dev/file/components/FileDetailSheet.tsx @@ -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 ( + + + + {file.fileType?.name || file.fileTypeId} + + + ID: {file.id} + + + } + icon={} + /> + + } + description={{ file.path }} + > + + + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/components/GitCommitViewDialog.tsx b/src/app/(main)/dev/file/components/GitCommitViewDialog.tsx new file mode 100644 index 0000000..ac1c04b --- /dev/null +++ b/src/app/(main)/dev/file/components/GitCommitViewDialog.tsx @@ -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 ( + + + + 查看 Commit {commitId} + {filePath} + + + + + 文件内容 + + 变更对比 + + + + + {isContentLoading ? ( +
+ + + +
+ ) : isContentError ? ( +
+ 加载失败: {contentError?.message || '未知错误'} +
+ ) : fileContent ? ( + + ) : ( +
+ 文件在此版本不存在 +
+ )} +
+ + + {!previousCommitId ? ( +
+ 这是第一个版本,没有可对比的内容 +
+ ) : isDiffLoading ? ( +
+ + + +
+ ) : isDiffError ? ( +
+ 加载失败: {diffError?.message || '未知错误'} +
+ ) : fileDiff ? ( + + ) : ( +
+ 无变更 +
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/components/FolderAnalyzeDialog.tsx b/src/app/(main)/dev/file/directory-tree/components/FolderAnalyzeDialog.tsx new file mode 100644 index 0000000..4cdb6cd --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/components/FolderAnalyzeDialog.tsx @@ -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 ( + + ) +} + +/** + * 文件夹分析进度对话框 + */ +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 ( +
+ {/* 进度统计 */} +
+ {progress.totalFolders !== undefined && ( +
+ 文件夹总数: + {progress.totalFolders} +
+ )} + {progress.analyzedFolders !== undefined && ( +
+ 已分析: + {progress.analyzedFolders} +
+ )} + {successCount > 0 && ( +
+ 成功: + {successCount} +
+ )} + {progress.failedFolders !== undefined && progress.failedFolders > 0 && ( +
+ 失败: + {progress.failedFolders} +
+ )} +
+ + {/* 当前处理的文件夹 */} + {progress.currentFolder && progress.state === 'active' && ( +
+
当前文件夹:
+
{progress.currentFolder}
+
+ )} + + {/* 最近的错误信息 */} + {progress.recentErrors && progress.recentErrors.length > 0 && ( +
+
最近的错误 (最多显示10条):
+
+ {progress.recentErrors.map((err, index) => ( +
+
{err.folderPath}
+
{err.error}
+ {index < progress.recentErrors!.length - 1 && ( +
+ )} +
+ ))} +
+
+ )} +
+ ) + } + + return ( + + 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} + /> + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/components/FolderDetailPanel.tsx b/src/app/(main)/dev/file/directory-tree/components/FolderDetailPanel.tsx new file mode 100644 index 0000000..94733c3 --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/components/FolderDetailPanel.tsx @@ -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 ( +
+
+ + +
+ +
+ ); + } + + if (isError || !folderDetail) { + return ( +
+
+
+ +

{name}

+
+

+ {path} +

+
+ +
+

类型

+ 文件夹 +
+ + {childrenCount > 0 && ( +
+

+ 子项数量 +

+

{childrenCount} 项

+
+ )} + + {isError && ( +
+

+ 暂无详细分析信息 +

+
+ )} +
+ ); + } + + return ( +
+ {/* 标题区域 */} +
+
+ +

{name}

+
+

+ {path} +

+
+ + {/* 基本信息 */} + + + 文件夹} + /> + {childrenCount > 0 && ( + + )} + + + + + + {/* 功能描述 */} + +
+ + + {folderDetail.description} +
+ } + /> +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/components/SearchDirectoryTree.tsx b/src/app/(main)/dev/file/directory-tree/components/SearchDirectoryTree.tsx new file mode 100644 index 0000000..6e38ddc --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/components/SearchDirectoryTree.tsx @@ -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 = {}): Record { + 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(initialExpandedItems); + const [, setSavedExpandedItems] = useState(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(); + + const matchedPaths = new Set(); + + // 找到所有匹配的项 + 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({ + 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(); + + // 收集所有匹配项的父节点路径 + 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 ( +
+ {/* 搜索和选项区域 */} +
+
+
+ { + 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 && ( + + {searchAllItems(debouncedSearchValue).length} 个匹配 + + )} +
+ +
+ setShowSummary(checked === true)} + /> + +
+
+
+ + {/* 树形列表 */} + +
+ + {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 ( + {/* data-search-match 默认样式比较丑,这里自己实现了就不要了这个data slot了 */} + { + // 点击时触发选中回调 + if (onItemSelect) { + onItemSelect(itemData); + } + }} + > +
+
+ {itemData.isFolder ? ( + item.isExpanded() ? ( + + ) : ( + + ) + ) : extension ? ( + + ) : ( + + )} + {itemData.name} +
+ + {itemData.summary && ( + + {itemData.summary} + + )} +
+
+
+ ); + })} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/page.dev.tsx b/src/app/(main)/dev/file/directory-tree/page.dev.tsx new file mode 100644 index 0000000..d8f5937 --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/page.dev.tsx @@ -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(null); + const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false); + const [analyzeJobId, setAnalyzeJobId] = useState(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 ( +
+
+ +

+ 选择一个文件或文件夹查看详情 +

+ {/* 文件夹分析按钮 */} +
+ +

+ 使用 AI 分析项目文件夹结构和用途 +

+
+
+
+ ); + } + + // 根据选中项类型显示对应的详情组件 + if (selectedItem.isFolder) { + return ( +
+ +
+ ); + } else { + return (
+
+
+ +

{selectedItem.name}

+
+

+ {selectedItem.path} +

+
+ +
+ ); + } + }; + + // 配置列 + const columns: CarouselColumn[] = [ + { + id: 'tree', + title: '目录树', + content: isLoading ? ( +
+ +
+ ) : error ? ( +
+

加载失败: {error.message}

+ +
+ ) : fileTree ? ( + + ) : ( +
+

暂无数据

+
+ ), + desktopClassName: 'w-1/3 border-r', + mobileClassName: '', + }, + { + id: 'detail', + title: '详情', + content: , + desktopClassName: 'w-2/3 bg-muted/20', + mobileClassName: 'bg-muted/20', + }, + ]; + + return ( + <> + + + {/* 文件夹分析进度对话框 */} + + + ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/graph/page.dev.tsx b/src/app/(main)/dev/file/graph/page.dev.tsx new file mode 100644 index 0000000..928ba39 --- /dev/null +++ b/src/app/(main)/dev/file/graph/page.dev.tsx @@ -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(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 ? ( +
+ +
+ ) : graphData ? ( + + ) : ( +
+

暂无依赖关系数据

+
+ )} + + {/* 文件详情Sheet */} + + + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/layout.dev.tsx b/src/app/(main)/dev/file/layout.dev.tsx new file mode 100644 index 0000000..0c98206 --- /dev/null +++ b/src/app/(main)/dev/file/layout.dev.tsx @@ -0,0 +1,9 @@ +import { SubMenuLayout } from "@/components/layout/sub-menu-layout"; + +export default function FilePageLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/list/page.dev.tsx b/src/app/(main)/dev/file/list/page.dev.tsx new file mode 100644 index 0000000..a1d8660 --- /dev/null +++ b/src/app/(main)/dev/file/list/page.dev.tsx @@ -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: ( + + + 最近分析时间 + + + + {isLoadingLatestTime ? ( + + ) : ( + <> +
+ {latestAnalyzedTime ? relativeTimeTitle : '-'} +
+

+ {latestAnalyzedTime + ? format(new Date(latestAnalyzedTime), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN }) + : '暂无数据'} +

+ + )} +
+
+ ), + }, + { + id: 'latest-commit', + title: '最新提交', + icon: GitCommit, + content: ( + + + 最新提交 + + + + {isLoadingCommits ? ( + + ) : ( + <> +
{latestCommit?.name.substring(0, 7) || '-'}
+

+ {latestCommit?.minAnalyzedAt + ? format(new Date(latestCommit.minAnalyzedAt), 'yyyy-MM-dd HH:mm', { locale: zhCN }) + : '暂无数据'} +

+ + )} +
+
+ ), + }, + { + id: 'total-files', + title: '文件总数', + icon: FileText, + content: ( + + + 文件总数 + + + + {isLoadingFileTypes ? ( + + ) : ( + <> +
{totalFiles}
+

已分析的源代码文件

+ + )} +
+
+ ), + }, + { + id: 'total-dependencies', + title: '依赖包数', + icon: Package, + content: ( + + + 依赖包数 + + + + {isLoadingPkgs ? ( + + ) : ( + <> +
{totalDependencies}
+

项目使用的包

+ + )} +
+
+ ), + }, + ], [relativeTimeTitle, latestAnalyzedTime, latestCommit, totalFiles, totalDependencies, isLoadingLatestTime, isLoadingFileTypes, isLoadingCommits, isLoadingPkgs]) + + return +} + +interface DevFilePageDataTableProps { + children?: React.ReactNode +} + +function DevFilePageDataTable({ children }: DevFilePageDataTableProps) { + // 详情Sheet状态 + const [selectedFile, setSelectedFile] = useState(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({ + 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 ( + <> + + + {children} + + + + + {/* 文件详情Sheet */} + + + ) +} + +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 ( +
+ {/* 统计概览区域 */} + + + {/* 文件列表表格 */} + + + }> + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/page.dev.tsx b/src/app/(main)/dev/file/page.dev.tsx new file mode 100644 index 0000000..38705a5 --- /dev/null +++ b/src/app/(main)/dev/file/page.dev.tsx @@ -0,0 +1,5 @@ +import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect"; + +export default function FilePage() { + return ; +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/layout.dev.tsx b/src/app/(main)/dev/frontend-design/layout.dev.tsx new file mode 100644 index 0000000..059c6f2 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/layout.dev.tsx @@ -0,0 +1,9 @@ +import { SubMenuLayout } from "@/components/layout/sub-menu-layout"; + +export default function FrontendDesignLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/page.dev.tsx b/src/app/(main)/dev/frontend-design/page.dev.tsx new file mode 100644 index 0000000..bf50396 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/page.dev.tsx @@ -0,0 +1,5 @@ +import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect"; + +export default function FrontendDesignPage() { + return ; +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/page/page.dev.tsx b/src/app/(main)/dev/frontend-design/page/page.dev.tsx new file mode 100644 index 0000000..65014a6 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/page/page.dev.tsx @@ -0,0 +1,19 @@ +"use client"; + +export default function PageTestPage() { + return ( +
+
+

页面测试

+

+ 在这里测试和展示完整的页面布局和功能 +

+
+ + {/* 这里可以添加完整页面的测试和展示 */} +
+ {/* 示例区域 */} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/ui/components/AddComponentSheet.tsx b/src/app/(main)/dev/frontend-design/ui/components/AddComponentSheet.tsx new file mode 100644 index 0000000..dfd9a81 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/ui/components/AddComponentSheet.tsx @@ -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([]); + const [searchResults, setSearchResults] = useState; + error?: string; + }>>([]); + + // 组件详情对话框状态 + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [selectedComponentName, setSelectedComponentName] = useState(""); + + // 搜索框ref,用于自动聚焦 + const searchInputRef = useRef(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 ( + + + +
+
+ +
+
+ 添加组件 + + 从第三方registry搜索并添加新的UI组件到项目中 + +
+
+
+ + + +
+ {/* Registry列表 */} +
+
+
+ +

+ 已选择 {selectedRegistries.length} / {registries?.length || 0} 个源 +

+
+
+ + +
+
+ + {isLoadingRegistries ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : registries && registries.length > 0 ? ( + ({ + 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" + /> + ) : ( +
+ +

+ 未找到可用的registry +

+
+ )} +
+ + + + {/* 搜索框 */} +
+ +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !searchMutation.isPending) { + handleSearch(); + } + }} + className="pl-9" + disabled={searchMutation.isPending} + /> +
+ +
+
+ + {/* 搜索结果 */} + {searchResults.length > 0 && ( +
+ +
+ + + {searchResults.reduce((sum, r) => sum + r.items.length, 0)} 个组件 + +
+
+ {searchResults.map((result) => { + // 查找对应registry的websiteUrl + const registryInfo = registries?.find(r => r.name === result.registry); + const websiteUrl = registryInfo?.websiteUrl; + + return ( +
+
+ + {result.registry} + + {result.error ? ( + + + {result.error} + + ) : ( + + {result.items.length} 个结果 + + )} + {websiteUrl && ( + + + + )} +
+ + {result.items.length > 0 && ( + {}} + 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 ? ( +
+ + {option.addCommandArgument} + +
+ ) : null + )} + renderActions={(option) => ( + + )} + /> + )} +
+ ); + })} +
+
+ )} +
+
+ + {/* 组件详情对话框 */} + +
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/ui/components/ComponentDetailDialog.tsx b/src/app/(main)/dev/frontend-design/ui/components/ComponentDetailDialog.tsx new file mode 100644 index 0000000..4d6d80b --- /dev/null +++ b/src/app/(main)/dev/frontend-design/ui/components/ComponentDetailDialog.tsx @@ -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 ( + + + +
+
+ +
+
+ + {componentName} + {componentInfo?.type && ( + + {componentInfo.type} + + )} + + + 组件详细信息 + +
+
+
+ + {viewComponentMutation.isPending ? ( +
+ + + +
+ ) : viewComponentMutation.error ? ( +
+

{viewComponentMutation.error.message}

+
+ ) : componentInfo ? ( +
+ {/* 添加组件命令 */} +
+

+ + 添加组件命令 +

+ +
+ + + + {/* 依赖信息 */} + {componentInfo.dependencies && componentInfo.dependencies.length > 0 && ( +
+

+ + 依赖项 +

+
+ {componentInfo.dependencies.map((dep: string) => ( + + {dep} + + ))} +
+
+ )} + + {/* 文件内容 */} + {componentInfo.files && componentInfo.files.length > 0 && ( +
+

+ + 文件内容 +

+ {componentInfo.files.length === 1 ? ( + + ) : ( + + + {componentInfo.files.map((file: any, index: number) => ( + + {file.path.split('/').pop()} + + ))} + + {componentInfo.files.map((file: any, index: number) => ( + + + + ))} + + )} +
+ )} +
+ ) : ( +
+

未找到组件信息

+
+ )} +
+
+ ); +} diff --git a/src/app/(main)/dev/frontend-design/ui/page.dev.tsx b/src/app/(main)/dev/frontend-design/ui/page.dev.tsx new file mode 100644 index 0000000..9b86454 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/ui/page.dev.tsx @@ -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([]); + const [generatedCode, setGeneratedCode] = useState(null); + const [componentScope, setComponentScope] = useState>({}); + 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 = { + 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 ( +
+
+ {/* 左侧:组件选择 */} + + +
+
+ +
+
+ 选择组件 + + 从项目中选择需要使用的UI组件 + +
+ +
+ +
+ + 已选择 {selectedComponents.length} 个 + + {filteredComponents.length < (components?.length || 0) && ( + + 显示 {filteredComponents.length}/{components?.length || 0} + + )} +
+
+ + {/* 搜索框 */} +
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> + {searchQuery && ( + + )} +
+
+ + {/* 组件列表区域 - 可滚动 */} +
+ {isLoadingComponents ? ( +
+
+ +

加载组件列表...

+
+
+ ) : filteredComponents.length > 0 ? ( + <> + +
+ ({ + 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) => ( +

+ {option.url} +

+ )} + /> +
+ + ) : searchQuery ? ( +
+ +

未找到匹配的组件

+

+ 尝试使用其他关键词搜索 +

+
+ ) : ( +
+ +

暂无可用的UI组件

+

+ 点击右上角“添加”按钮从registry导入组件 +

+
+ )} +
+ + + + {/* AI 提示词输入框 */} +
+ + + + + {(attachment) => } + + + + + + + + + + + + + + + +
+
+
+ + {/* 右侧:代码预览 */} +
+ + +
+
+ +
+
+ 代码预览 + + 实时编辑和预览生成的组件效果 + +
+ {generatedCode && ( + + + 实时预览 + + )} +
+ +
+ + {status === "streaming" || generatedCode ? ( + + ) : ( +
+
+
+ +
+
+

+ 准备开始创建 +

+

+ 选择需要使用的组件,然后在左侧输入提示词,AI将为你生成可预览的代码 +

+
+
+
+ )} +
+
+
+
+ + {/* 添加组件Sheet */} + +
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/layout.dev.tsx b/src/app/(main)/dev/layout.dev.tsx new file mode 100644 index 0000000..46e28e2 --- /dev/null +++ b/src/app/(main)/dev/layout.dev.tsx @@ -0,0 +1,15 @@ +"use client"; + +import "./dev-theme.css"; + +export default function DevLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + {children} + + ); +} diff --git a/src/app/(main)/dev/panel/agents-config.tsx b/src/app/(main)/dev/panel/agents-config.tsx new file mode 100644 index 0000000..bcada9d --- /dev/null +++ b/src/app/(main)/dev/panel/agents-config.tsx @@ -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) +} \ No newline at end of file diff --git a/src/app/(main)/dev/panel/components/version-control.tsx b/src/app/(main)/dev/panel/components/version-control.tsx new file mode 100644 index 0000000..2fb60bf --- /dev/null +++ b/src/app/(main)/dev/panel/components/version-control.tsx @@ -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('') + 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(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 ( +
+ {/* 分支选择器和操作按钮 */} +
+
+ {/* 左半部分:分支选择器 */} +
+ +
+ {branchesLoading ? ( + + ) : ( + + + + + + + + + + + )} +
+
+ + {/* 右半部分:Commit按钮和刷新按钮 */} +
+ + +
+
+ {hasChanges && ( +
+ + 有未提交的更改 +
+ )} + {selectedBranch !== currentBranchData?.branch && ( +
+ 当前查看 {selectedBranch} 分支的提交历史 +
+ )} +
+ + + + {/* 提交历史 */} +
+ e.stopPropagation()} + > + {isInitialLoad && commitsLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ) : (!commits || commits.length === 0) ? ( + 暂无提交记录 + ) : ( + + {commits.map((commit, index) => { + const isAfterHead = commit.isAfterHead + const isFirstCommit = index === 0 + + return ( + + + + + + + + + {commit.message} + + + + + + + + + handleCheckoutCommit(commit.shortId, commit.message, isFirstCommit)} + disabled={hasChanges} + > + + {isFirstCommit ? '切换到此分支' : '切换到此提交'} + + + handleRevert(commit.shortId, commit.message)} + > + + 反转提交 + + handleReset(commit.shortId, commit.message)} + > + + 强制回滚 + + + + + + + + + + Commit ID: {commit.shortId} + + + + + + ) + })} + + )} + {/* 加载更多指示器 */} + {isLoadingMore && !isInitialLoad && ( +
+ + 加载更多... +
+ )} +
+
+ + {/* Commit对话框 */} + + + + 提交更改 + + 请输入提交信息来描述本次更改 + + +
+
+ +