Release v0.1.0: 小程序使用建议跳转腾讯文档 + 配置导入导出功能

This commit is contained in:
权益小助手开发
2026-05-04 17:09:07 +08:00
commit 7d90523164
89 changed files with 18289 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
---
name: "bugpack-deploy"
description: "Deploy and configure BugPack (bug screenshot to AI instructions tool) on Windows with MSYS2. Invoke when user asks to setup/install/deploy BugPack or fix its build/runtime issues."
---
# BugPack Deploy Skill
This skill guides the deployment of [BugPack](https://github.com/duhuazhu/BugPack) on Windows using MSYS2 environment.
## Prerequisites
- Windows OS
- MSYS2 installed (usually at `C:\msys64` or `D:\msys64`)
- Internet connection for downloading packages
## Environment Setup
### 1. MSYS2 Configuration
Open MSYS2 terminal and update the system:
```bash
pacman -Syu
# If prompted to close terminal, close and reopen, then run:
pacman -Su
```
### 2. Install Required Tools
```bash
pacman -S --noconfirm git curl make
pacman -S --noconfirm mingw-w64-x86_64-nodejs
pacman -S --noconfirm mingw-w64-x86_64-gcc
```
### 3. Node.js Environment Variables
Add to `~/.bashrc` or export manually:
```bash
export PATH="/mingw64/bin:$PATH"
export LD_LIBRARY_PATH="/mingw64/bin:$LD_LIBRARY_PATH"
```
**Note**: `LD_LIBRARY_PATH` is required because `libnode.dll` is in `/mingw64/bin`.
### 4. Create Node.js Symlinks (Optional but recommended)
```bash
ln -sf /mingw64/bin/node.exe /usr/bin/node
ln -sf /mingw64/lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm
ln -sf /mingw64/lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx
```
## Deployment Steps
### 1. Download BugPack
```bash
# Option A: Clone with git
git clone https://github.com/duhuazhu/BugPack.git
cd BugPack
# Option B: Download and extract ZIP
curl -L -o BugPack.zip https://github.com/duhuazhu/BugPack/archive/refs/heads/main.zip
unzip BugPack.zip
cd BugPack-main
```
### 2. Install Dependencies
```bash
npm install
```
### 3. Build Project
```bash
npm run build
```
### 4. Fix Static File Path (Production Mode)
**Issue**: Server looks for `src/client/index.html` but built files are in `dist/client/`.
**Fix**: Edit `src/server/index.ts`:
```typescript
// Before (line ~47):
const clientDir = path.resolve(__dirname, '../client')
// After:
const clientDir = path.resolve(__dirname, '../../dist/client')
const devClientDir = path.resolve(__dirname, '../client')
const staticDir = fs.existsSync(clientDir) ? clientDir : devClientDir
if (fs.existsSync(staticDir)) {
app.use(express.static(staticDir))
app.get('*', (req, res) => {
if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads')) {
res.sendFile(path.join(staticDir, 'index.html'))
}
})
}
```
### 5. Start Server
```bash
# Development mode (with auto-reload)
npm run dev:server
# Or specify custom port
PORT=3458 npm run dev:server
```
Server will start at `http://localhost:3457` (or your custom port).
## Common Issues & Solutions
### Issue 1: `node: command not found`
**Cause**: Node.js installed in `/mingw64/bin` but not in PATH.
**Solution**:
```bash
export PATH="/mingw64/bin:$PATH"
```
### Issue 2: `libnode.dll: cannot open shared object file`
**Cause**: DLL not found in library path.
**Solution**:
```bash
export LD_LIBRARY_PATH="/mingw64/bin:$LD_LIBRARY_PATH"
```
### Issue 3: `better-sqlite3` build fails with `make: cc: No such file`
**Cause**: C compiler not installed.
**Solution**:
```bash
pacman -S --noconfirm mingw-w64-x86_64-gcc make
```
### Issue 4: `ENOENT: no such file or directory, stat '.../src/client/index.html'`
**Cause**: Server configured for dev mode but running production build.
**Solution**: Apply the static file path fix in Step 4 above.
### Issue 5: `EADDRINUSE: address already in use :::3457`
**Cause**: Another instance is running.
**Solution**:
```bash
# Use different port
PORT=3458 npm run dev:server
```
### Issue 6: MSYS2 mirror network issues
**Symptom**: All mirrors return "Could not resolve host".
**Solution**: Network environment issue. Try:
1. Check internet connection
2. Switch to different network
3. Or configure specific mirror:
```bash
cat > /etc/pacman.d/mirrorlist.mingw64 << 'EOF'
Server = https://mirrors.tuna.tsinghua.edu.cn/msys2/mingw/x86_64/
Server = https://mirrors.ustc.edu.cn/msys2/mingw/x86_64/
EOF
pacman -Syy
```
## Verification
After deployment, verify:
```bash
# Check server is running
curl -s http://localhost:3457 | head -5
# Expected output: HTML content starting with <!DOCTYPE html>
```
## MCP Server Configuration
To integrate with Trae/Cursor/Claude Code, add MCP config:
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
## Tech Stack
| Component | Technology |
|-----------|------------|
| Frontend | React 18 + TypeScript + Tailwind CSS |
| Backend | Express + Node.js |
| Database | SQLite (better-sqlite3) |
| Annotation | Fabric.js v6 |
| Build Tool | Vite |
## Data Storage
All data stored locally:
- **Data directory**: `~/.bugpack/data/`
- **Database**: `bugpack.db`
- **Screenshots**: `uploads/{ProjectName}/{uuid}.{ext}`
+1
View File
@@ -0,0 +1 @@
ko_fi: W7W51W5EN5
+47
View File
@@ -0,0 +1,47 @@
name: Bug Report
description: 报告一个 Bug / Report a bug
labels: [bug]
body:
- type: textarea
id: description
attributes:
label: 问题描述 / Description
description: 请清楚描述遇到的问题 / Describe the bug clearly
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤 / Steps to Reproduce
description: 如何复现这个问题 / How to reproduce
placeholder: |
1. ...
2. ...
3. ...
- type: textarea
id: expected
attributes:
label: 期望行为 / Expected Behavior
- type: input
id: version
attributes:
label: BugPack 版本 / Version
placeholder: "0.1.0"
- type: input
id: node-version
attributes:
label: Node.js 版本 / Node.js Version
placeholder: ">= 18.x"
- type: dropdown
id: os
attributes:
label: 操作系统 / OS
options:
- macOS
- Windows
- Linux
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussions
url: https://github.com/duhuazhu/BugPack/discussions
about: Ask questions, share ideas, or discuss BugPack here
@@ -0,0 +1,17 @@
name: Feature Request
description: 提一个新功能建议 / Suggest a new feature
labels: [enhancement]
body:
- type: textarea
id: description
attributes:
label: 功能描述 / Description
description: 你希望 BugPack 增加什么功能?/ What feature would you like?
validations:
required: true
- type: textarea
id: use-case
attributes:
label: 使用场景 / Use Case
description: 这个功能解决什么问题?/ What problem does this solve?
+13
View File
@@ -0,0 +1,13 @@
## 改了什么 / What Changed
<!-- 简要描述你的改动 / Brief description of changes -->
## 为什么改 / Why
<!-- 解决了什么问题或实现了什么功能 / What problem does this solve -->
## 测试 / Testing
- [ ] `npx tsc --noEmit` 通过
- [ ] `npm run build` 通过
- [ ] 已在本地测试功能正常
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ">= 18"
cache: npm
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
+38
View File
@@ -0,0 +1,38 @@
node_modules/
dist/
data/
# Database
*.db
*.db-shm
*.db-wal
*.db-journal
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Project internal
todo/
.mcp.json
/产品录屏/
/录制项目的视频/
BugPack-产品构思文档.md
# Build cache
*.tsbuildinfo
# Logs
*.log
*.pid
+23
View File
@@ -0,0 +1,23 @@
src/
docs/
todo/
*.db
*.db-shm
*.db-wal
*.db-journal
data/
.env
.env.*
.idea/
.vscode/
.claude/
.cursor/
.windsurf/
.mcp.json
.gitignore
tsconfig*.json
vite.config.ts
postcss.config.js
tailwind.config.js
BugPack-*.md
node_modules/
+25
View File
@@ -0,0 +1,25 @@
English | [中文](CHANGELOG.zh-CN.md)
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [1.0.0] - 2026-03-18
### Added
- Bug management with multi-project support
- Screenshot paste (Ctrl+V) and annotation tools (arrow, rectangle, text, numbering, etc.)
- AI instruction generation from annotated screenshots
- MCP Server integration for 10+ AI coding tools (Claude Code, Cursor, Windsurf, VS Code, Cline, etc.)
- Batch operations (multi-select, batch delete, batch status change, batch export)
- Import/Export (.bugpack zip format)
- Third-party platform integration (Jira, Linear, Zentao, TAPD)
- Dark/Light theme support
- i18n support (Chinese / English)
- Keyboard shortcuts (Ctrl+N, Ctrl+Enter, Ctrl+Z/Y, etc.)
- OpenClaw Skills for AI agent workflows
- GitHub Actions CI pipeline (Node >= 18)
- Community files: LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY
+25
View File
@@ -0,0 +1,25 @@
[English](CHANGELOG.md) | 中文
# 更新日志
本项目的所有重要变更都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/),版本号遵循 [语义化版本](https://semver.org/)。
## [1.0.0] - 2026-03-18
### 新增
- Bug 管理,支持多项目
- 截图粘贴(Ctrl+V)和标注工具(箭头、矩形、文字、编号等)
- 从标注截图生成 AI 修复指令
- MCP Server 集成,支持 10+ AI 编程工具(Claude Code、Cursor、Windsurf、VS Code、Cline 等)
- 批量操作(多选、批量删除、批量改状态、批量导出)
- 导入/导出(.bugpack zip 格式)
- 第三方平台集成(Jira、Linear、禅道、TAPD
- 深色/浅色主题
- 国际化(中文/英文)
- 快捷键(Ctrl+N、Ctrl+Enter、Ctrl+Z/Y 等)
- OpenClaw 技能包,支持 AI 工作流
- GitHub Actions CI 流水线(Node >= 18
- 社区文件:LICENSE、CONTRIBUTING、CODE_OF_CONDUCT、SECURITY
+30
View File
@@ -0,0 +1,30 @@
English | [中文](CODE_OF_CONDUCT.zh-CN.md)
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to a positive environment:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
Examples of unacceptable behavior:
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting the maintainer directly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
@@ -0,0 +1,30 @@
[English](CODE_OF_CONDUCT.md) | 中文
# 贡献者行为准则
## 我们的承诺
作为成员、贡献者和领导者,我们承诺让每个人在社区中都能拥有无骚扰的参与体验,无论年龄、体型、残障与否、种族、性别特征、性别认同与表达、经验水平、教育程度、社会经济地位、国籍、外貌、宗教或性取向。
## 我们的标准
有助于营造积极环境的行为:
- 使用友好和包容的语言
- 尊重不同的观点和经验
- 优雅地接受建设性批评
- 关注对社区最有利的事情
不可接受的行为:
- 挑衅、侮辱或贬损性评论,以及人身或政治攻击
- 公开或私下骚扰
- 未经明确许可发布他人的私人信息
## 执行
如遇到滥用、骚扰或其他不可接受的行为,可通过提交 Issue 或直接联系维护者进行举报。
## 归属
本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org/) 2.1 版。
+55
View File
@@ -0,0 +1,55 @@
English | [中文](CONTRIBUTING.zh-CN.md)
# Contributing
Thanks for your interest in BugPack!
## Using BugPack
If you just want to use BugPack, no need to clone — run it directly via npm:
```bash
# Start Web UI
npx bugpack-mcp
# Start MCP Server (for AI coding tools)
npx bugpack-mcp --mcp
```
## Contributing Code
To contribute code to BugPack:
1. Fork and clone the repository
2. `npm install`
3. `npm run dev:all` to start dev mode (frontend + backend with hot reload)
4. Develop on a `feature/xxx` branch
5. Submit a PR
## Dev Commands
| Command | Description |
|---------|-------------|
| `npm run dev:all` | Start frontend + backend (dev mode) |
| `npm run build` | Production build |
## Project Structure
```
src/
├── client/ # React frontend
│ ├── components/ # UI components
│ ├── stores/ # Zustand state management
│ ├── hooks/ # Custom hooks
│ ├── i18n/ # Internationalization (zh/en)
│ └── utils/ # Utilities (instruction generation)
├── server/ # Express backend
│ ├── routes/ # API routes
│ └── db.ts # SQLite database
└── mcp/ # MCP Server (stdio transport)
```
## Guidelines
- Ensure `npx tsc --noEmit` passes before submitting
- Update i18n files in `src/client/i18n/` for both zh and en if applicable
+55
View File
@@ -0,0 +1,55 @@
[English](CONTRIBUTING.md) | 中文
# 贡献指南
感谢你对 BugPack 的关注!
## 使用 BugPack
如果只是使用 BugPack,无需克隆仓库,直接通过 npm 运行:
```bash
# 启动 Web UI
npx bugpack-mcp
# 启动 MCP Server(供 AI 编程工具使用)
npx bugpack-mcp --mcp
```
## 贡献代码
参与 BugPack 开发:
1. Fork 并克隆仓库
2. `npm install`
3. `npm run dev:all` 启动开发模式(前端 + 后端热重载)
4.`feature/xxx` 分支上开发
5. 提交 PR
## 开发命令
| 命令 | 说明 |
|------|------|
| `npm run dev:all` | 启动前端 + 后端(开发模式) |
| `npm run build` | 生产构建 |
## 项目结构
```
src/
├── client/ # React 前端
│ ├── components/ # UI 组件
│ ├── stores/ # Zustand 状态管理
│ ├── hooks/ # 自定义 Hooks
│ ├── i18n/ # 国际化(中文/英文)
│ └── utils/ # 工具函数(指令生成)
├── server/ # Express 后端
│ ├── routes/ # API 路由
│ └── db.ts # SQLite 数据库
└── mcp/ # MCP Serverstdio 传输)
```
## 规范
- 提交前确保 `npx tsc --noEmit` 通过
- 如涉及界面文案,需同时更新 `src/client/i18n/` 中的中英文文件
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026-present duhuazhu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+317
View File
@@ -0,0 +1,317 @@
<p align="center">
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/public/favicon.svg" width="80" alt="BugPack">
</p>
<h1 align="center">BugPack</h1>
<p align="center">
<strong>Package bug screenshots into AI-ready fix instructions in 30 seconds</strong>
</p>
<p align="center">
<a href="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml"><img src="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/v/bugpack-mcp.svg" alt="npm version"></a>
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/dm/bugpack-mcp.svg" alt="npm downloads"></a>
<a href="https://github.com/duhuazhu/BugPack/blob/main/LICENSE"><img src="https://img.shields.io/github/license/duhuazhu/BugPack.svg" alt="license"></a>
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> · <a href="#mcp-configuration">MCP Config</a> · <a href="#openclaw-skills">OpenClaw</a> · <a href="#features">Features</a> · <a href="#platform-integrations">Integrations</a>
</p>
<p align="center">
English | <a href="README.zh-CN.md">中文</a>
</p>
---
![BugPack Demo](https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/demo.gif)
---
## What is BugPack?
BugPack is a **local-first** tool that packages bug screenshots into structured, AI-ready fix instructions.
QA drops a screenshot in the chat → you `Ctrl+V` paste it into BugPack → annotate the issue → generate structured instructions → feed them to your AI coding agent.
Or skip the copy-paste entirely: BugPack's built-in **MCP Server** lets any MCP-compatible AI coding tool (Claude Code, Cursor, Windsurf, Cline, etc.) **read bug context and fix code automatically**.
## Why BugPack?
AI coding agents changed how we write code, but not how we **communicate bug context**.
Every bug fix still requires: save screenshot → create file → write paths → describe the issue → paste to AI.
10 bugs a day = **1-2 hours of pure repetition**.
BugPack compresses this to **30 seconds**.
## Requirements
- **Node.js** >= 18
- **OS** — Windows / macOS / Linux
- **Browser** — Chrome / Edge / Firefox (Chrome recommended)
## Quick Start
```bash
npx bugpack-mcp
```
Open `http://localhost:3456` and `Ctrl+V` your first bug screenshot to get started.
## MCP Configuration
BugPack works with **any MCP-compatible AI coding tool**. Here are common examples — configure other tools the same way.
**Claude Code** — add to `~/.claude.json`:
```json
{
"mcpServers": {
"bugpack": {
"type": "stdio",
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
<details>
<summary><b>Cursor / Windsurf / VS Code / Cline / Roo Code / Trae / MarsCode / Augment</b></summary>
**Cursor** (`.cursor/mcp.json`):
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`):
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**VS Code** (`.vscode/mcp.json`):
```json
{
"servers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Cline / Roo Code** (VS Code Settings):
```json
{
"cline.mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Trae** (`trae/mcp.json`):
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**MarsCode** (Settings → MCP):
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Augment** (`augment/mcp.json`):
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
All other MCP-compatible tools follow the same pattern — just point `command` to `npx` and `args` to `["bugpack-mcp", "--mcp"]`.
</details>
Once configured, just tell your AI:
- **"Show me pending bugs"** → AI calls `list_bugs`
- **"Fix bug #3"** → AI calls `get_bug_context`, locates code, and fixes it
- **"Mark bug #3 as fixed"** → AI calls `mark_bug_status`
## OpenClaw Skills
BugPack provides [OpenClaw](https://github.com/openclaw/openclaw) Skills for AI agents that support the OpenClaw protocol.
**Install via CLI:**
```bash
clawhub install bugpack
```
**Or add to `~/.openclaw/openclaw.json`:**
```json
{
"skills": {
"entries": {
"bugpack": {
"enabled": true
}
},
"extraDirs": ["./skills"]
}
}
```
**Or manually**: copy the `skills/` directory from this repo into your workspace or `~/.openclaw/skills/`.
BugPack includes 3 built-in skills:
| Skill | Triggers | Description |
|-------|----------|-------------|
| `bugpack-list-bugs` | "show me bugs" / "list bugs" | List all bugs with status filtering |
| `bugpack-view-bug` | "view bug" / "bug context" | Get full bug details with screenshots and related files |
| `bugpack-fix-bug` | "fix bug" / "repair bug" | Read context → locate code → apply fix → update status |
Once installed, just tell your AI:
- **"Show me bugs"** → AI calls `bugpack-list-bugs`
- **"View bug details"** → AI calls `bugpack-view-bug`, shows screenshots and context
- **"Fix this bug"** → AI calls `bugpack-fix-bug`, locates code, fixes it, and marks as done
> **Note:** OpenClaw Skills require BugPack server running (`npx bugpack-mcp`). Skills communicate with the local server via REST API on `http://localhost:3456`.
## Features
### Screenshots & Annotations
- **Clipboard paste** — `Ctrl+V` to paste screenshots directly from any chat tool
- **Drag & drop** — drop image files onto the canvas
- **9 annotation tools** — drag/pan, select, rectangle, arrow, text, numbering, highlight, pen, mosaic
- **Compare mode** — side-by-side comparison of "current" vs "expected" behavior
- **Undo / Redo** — full operation history
### AI Instruction Generation
- **One-click generation** — produces structured Markdown fix instructions
- **Universal MCP support** — works with any MCP-compatible AI coding tool
### MCP Server
Built-in MCP Server lets AI coding agents **directly access bug context**:
| Tool | Description |
|------|-------------|
| `list_bugs` | List all bugs with status/project filtering |
| `get_bug_context` | Get full bug context (description + screenshots + environment + files) |
| `get_bug_screenshot` | Get a single annotated screenshot (base64) |
| `mark_bug_status` | Update bug status |
| `add_fix_note` | Add fix notes after repair |
### Platform Integrations
Import bugs from project management platforms, sync fix status back:
- **Zentao** · **Jira** · **Linear** · **TAPD**
### More
- **100% local** — data never leaves your machine, SQLite storage
- **Multi-project** — manage bugs independently per project
- **Dark / Light theme** — follow your preference
- **i18n** — Chinese / English
- **Keyboard shortcuts** — efficient workflow
## Workflow
```
Paste screenshot → Describe issue → Generate instructions → AI fixes code
│ │ │
│ ┌──────────────┘ │
▼ ▼ ▼
BugPack Copy Markdown MCP Server
Canvas paste to AI tool AI reads & fixes directly
```
## Data Storage
All data is stored locally:
- **Data directory**: `~/.bugpack/data/`
- **Database**: `bugpack.db` (SQLite)
- **Screenshots**: `uploads/{ProjectName}/{uuid}.{ext}`
## Tech Stack
| Layer | Technology |
|-------|------------|
| Frontend | React 18 · TypeScript · Tailwind CSS · Zustand |
| Annotation | Fabric.js v6 |
| Backend | Node.js · Express |
| Database | SQLite (better-sqlite3, WAL mode) |
| MCP | @modelcontextprotocol/sdk |
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
[MIT](LICENSE)
---
<div align="center">
**If BugPack saves you time, give it a Star!**
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/W7W51W5EN5)
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="Alipay">&nbsp;&nbsp;&nbsp;&nbsp;<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="WeChat Pay">
</div>
+310
View File
@@ -0,0 +1,310 @@
<p align="center">
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/public/favicon.svg" width="80" alt="BugPack">
</p>
<h1 align="center">BugPack</h1>
<p align="center">
<strong>30 秒将 Bug 截图打包为 AI 可读的修复指令</strong>
</p>
<p align="center">
<a href="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml"><img src="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/v/bugpack-mcp.svg" alt="npm version"></a>
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/dm/bugpack-mcp.svg" alt="npm downloads"></a>
<a href="https://github.com/duhuazhu/BugPack/blob/main/LICENSE"><img src="https://img.shields.io/github/license/duhuazhu/BugPack.svg" alt="license"></a>
</p>
<p align="center">
<a href="#快速开始">快速开始</a> · <a href="#mcp-配置">MCP 配置</a> · <a href="#openclaw-技能">OpenClaw</a> · <a href="#功能特性">功能特性</a> · <a href="#平台集成">平台集成</a>
</p>
<p align="center">
<a href="README.md">English</a> | 中文
</p>
---
![BugPack Demo](https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/demo.gif)
---
## 什么是 BugPack
BugPack 是一个**本地优先**的工具,将 Bug 截图打包为结构化的、AI 可读的修复指令。
测试人员在群里发了截图 → 你 `Ctrl+V` 粘贴到 BugPack → 标注问题区域 → 生成结构化指令 → 喂给 AI 编程助手。
或者跳过复制粘贴:BugPack 内置 **MCP Server**,让任何兼容 MCP 的 AI 编程工具(Claude Code、Cursor、Windsurf、Cline 等)**直接读取 Bug 上下文并自动修复代码**。
## 为什么用 BugPack
AI 编程助手改变了我们写代码的方式,但没有改变我们**传递 Bug 上下文**的方式。
每次修 Bug 仍然需要:保存截图 → 创建文件 → 写路径 → 描述问题 → 粘贴给 AI。
一天 10 个 Bug = **1-2 小时的纯重复劳动**
BugPack 把这个过程压缩到 **30 秒**
## 环境要求
- **Node.js** >= 18
- **操作系统** — Windows / macOS / Linux
- **浏览器** — Chrome / Edge / Firefox(推荐 Chrome
## 快速开始
```bash
npx bugpack-mcp
```
打开 `http://localhost:3456``Ctrl+V` 粘贴你的第一张 Bug 截图即可开始。
## MCP 配置
BugPack 兼容**任何支持 MCP 的 AI 编程工具**。以下是常见配置示例。
**Claude Code** — 添加到 `~/.claude.json`
```json
{
"mcpServers": {
"bugpack": {
"type": "stdio",
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
<details>
<summary><b>Cursor / Windsurf / VS Code / Cline / Roo Code / Trae / MarsCode / Augment</b></summary>
**Cursor** (`.cursor/mcp.json`)
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`)
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**VS Code** (`.vscode/mcp.json`)
```json
{
"servers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Cline / Roo Code**VS Code 设置):
```json
{
"cline.mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Trae** (`trae/mcp.json`)
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**MarsCode**(设置 → MCP):
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
**Augment** (`augment/mcp.json`)
```json
{
"mcpServers": {
"bugpack": {
"command": "npx",
"args": ["bugpack-mcp", "--mcp"]
}
}
}
```
所有兼容 MCP 的工具配置方式相同 — `command` 指向 `npx``args` 设为 `["bugpack-mcp", "--mcp"]`
</details>
配置完成后,直接告诉你的 AI
- **"显示待修复的 Bug"** → AI 调用 `list_bugs`
- **"修复 Bug #3"** → AI 调用 `get_bug_context`,定位代码并修复
- **"标记 Bug #3 为已修复"** → AI 调用 `mark_bug_status`
## OpenClaw 技能
BugPack 提供 [OpenClaw](https://github.com/openclaw/openclaw) 技能包,支持 OpenClaw 协议的 AI 助手可直接使用。
**通过 CLI 安装:**
```bash
clawhub install bugpack
```
**或添加到 `~/.openclaw/openclaw.json`**
```json
{
"skills": {
"entries": {
"bugpack": {
"enabled": true
}
},
"extraDirs": ["./skills"]
}
}
```
**或手动安装**:将本仓库的 `skills/` 目录复制到你的工作区或 `~/.openclaw/skills/`
BugPack 包含 3 个内置技能:
| 技能 | 触发方式 | 说明 |
|------|----------|------|
| `bugpack-list-bugs` | "显示 Bug" / "列出 Bug" | 列出所有 Bug,支持状态过滤 |
| `bugpack-view-bug` | "查看 Bug" / "Bug 详情" | 获取完整 Bug 详情,包含截图和关联文件 |
| `bugpack-fix-bug` | "修复 Bug" / "修 Bug" | 读取上下文 → 定位代码 → 修复 → 更新状态 |
> **注意:** OpenClaw 技能需要 BugPack 服务运行中(`npx bugpack-mcp`)。技能通过 REST API 与本地服务通信(`http://localhost:3456`)。
## 功能特性
### 截图与标注
- **剪贴板粘贴** — `Ctrl+V` 从任何聊天工具直接粘贴截图
- **拖放上传** — 拖放图片文件到画布
- **9 种标注工具** — 拖拽/平移、选择、矩形、箭头、文字、编号、高亮、画笔、马赛克
- **对比模式** — 并排对比"当前效果"与"预期效果"
- **撤销/重做** — 完整操作历史
### AI 指令生成
- **一键生成** — 生成结构化 Markdown 修复指令
- **通用 MCP 支持** — 兼容任何支持 MCP 的 AI 编程工具
### MCP Server
内置 MCP Server 让 AI 编程助手**直接访问 Bug 上下文**:
| 工具 | 说明 |
|------|------|
| `list_bugs` | 列出所有 Bug,支持状态/项目过滤 |
| `get_bug_context` | 获取完整 Bug 上下文(描述 + 截图 + 环境 + 文件) |
| `get_bug_screenshot` | 获取单张标注截图(base64) |
| `mark_bug_status` | 更新 Bug 状态 |
| `add_fix_note` | 修复后添加备注 |
### 平台集成
从项目管理平台导入 Bug,同步修复状态:
- **禅道** · **Jira** · **Linear** · **TAPD**
### 更多
- **100% 本地** — 数据不离开你的机器,SQLite 存储
- **多项目管理** — 按项目独立管理 Bug
- **深色/浅色主题** — 跟随你的偏好
- **国际化** — 中文 / 英文
- **快捷键** — 高效工作流
## 工作流
```
粘贴截图 → 描述问题 → 生成指令 → AI 修复代码
│ │ │
│ ┌────────────┘ │
▼ ▼ ▼
BugPack 复制 Markdown MCP Server
画布 粘贴给 AI 工具 AI 直接读取并修复
```
## 数据存储
所有数据存储在本地:
- **数据目录**`~/.bugpack/data/`
- **数据库**`bugpack.db`SQLite
- **截图**`uploads/{项目名}/{uuid}.{ext}`
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端 | React 18 · TypeScript · Tailwind CSS · Zustand |
| 标注 | Fabric.js v6 |
| 后端 | Node.js · Express |
| 数据库 | SQLite (better-sqlite3, WAL 模式) |
| MCP | @modelcontextprotocol/sdk |
## 贡献
请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。
## 许可证
[MIT](LICENSE)
---
<div align="center">
**如果 BugPack 帮到了你,请给个 Star**
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/W7W51W5EN5)
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="支付宝">&nbsp;&nbsp;&nbsp;&nbsp;<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="微信支付">
</div>
+26
View File
@@ -0,0 +1,26 @@
English | [中文](SECURITY.zh-CN.md)
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability in BugPack, please report it responsibly:
1. **Do NOT** open a public GitHub issue
2. Email the maintainer directly or use [GitHub private vulnerability reporting](https://github.com/duhuazhu/BugPack/security/advisories/new)
3. Include steps to reproduce the issue
We will respond within 72 hours and work on a fix as soon as possible.
## Scope
BugPack runs **100% locally** on your machine. All data (SQLite database, screenshots) is stored in `~/.bugpack/data/` and never transmitted externally.
The MCP Server communicates via **stdio** only — no network exposure.
## Supported Versions
| Version | Supported |
|---------|-----------|
| 1.x | Yes |
| < 1.0 | No |
+26
View File
@@ -0,0 +1,26 @@
[English](SECURITY.md) | 中文
# 安全策略
## 报告漏洞
如果你发现 BugPack 中的安全漏洞,请负责任地报告:
1. **不要**创建公开的 GitHub Issue
2. 直接联系维护者,或使用 [GitHub 私密漏洞报告](https://github.com/duhuazhu/BugPack/security/advisories/new)
3. 附上复现步骤
我们会在 72 小时内回复,并尽快修复。
## 范围
BugPack **100% 本地运行**。所有数据(SQLite 数据库、截图)存储在 `~/.bugpack/data/`,不会传输到外部。
MCP Server 仅通过 **stdio** 通信,无网络暴露。
## 支持版本
| 版本 | 是否支持 |
|------|----------|
| 1.x | 是 |
| < 1.0 | 否 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { fileURLToPath, pathToFileURL } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
if (process.argv.includes('--mcp')) {
await import(pathToFileURL(path.join(__dirname, '../dist/mcp/index.js')).href)
} else {
// Default port 3456 for production
if (!process.env.PORT) process.env.PORT = '3456'
await import(pathToFileURL(path.join(__dirname, '../dist/server/index.js')).href)
}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Pack bug screenshots into AI-ready fix instructions with MCP Server integration" />
<meta name="theme-color" content="#002FA7" />
<meta property="og:title" content="BugPack" />
<meta property="og:description" content="Pack bug screenshots into AI-ready fix instructions with MCP Server integration" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://github.com/duhuazhu/BugPack" />
<title>BugPack</title>
<script>
// Sync theme before render to prevent flash
try {
var xhr = new XMLHttpRequest()
xhr.open('GET', '/api/settings', false)
xhr.send()
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText)
if (data.theme === 'light') {
document.documentElement.classList.add('light')
}
}
} catch(e) {}
</script>
</head>
<body class="bg-bg-primary text-text-primary">
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>
+8061
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
{
"name": "bugpack-mcp",
"version": "1.0.5",
"description": "Pack bug screenshots into AI coding instructions with MCP Server integration",
"author": "duhuazhu",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/duhuazhu/BugPack"
},
"homepage": "https://github.com/duhuazhu/BugPack",
"bugs": {
"url": "https://github.com/duhuazhu/BugPack/issues"
},
"keywords": [
"mcp",
"mcp-server",
"model-context-protocol",
"bug",
"bug-tracking",
"bug-report",
"screenshot",
"annotation",
"ai",
"claude",
"cursor",
"windsurf",
"vscode",
"cline",
"trae",
"marscode",
"developer-tools",
"qa",
"testing",
"fix-instructions",
"code-fix"
],
"type": "module",
"bin": {
"bugpack-mcp": "bin/bugpack.mjs"
},
"files": [
"bin/",
"dist/",
"LICENSE",
"README.md"
],
"scripts": {
"dev": "vite",
"dev:server": "tsx watch src/server/index.ts",
"dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"",
"mcp": "tsx src/mcp/index.ts",
"build": "vite build && tsc -p tsconfig.server.json",
"prepublishOnly": "npm run build",
"preview": "vite preview"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"multer": "^1.4.5-lts.1",
"uuid": "^11.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0",
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
"fabric": "^6.6.1",
"lucide-react": "^0.469.0",
"marked": "^17.0.4",
"morphdom": "^2.7.8",
"postcss": "^8.4.49",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"zustand": "^5.0.3"
},
"engines": {
"node": ">=18"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="450 105 490 550">
<path d="M 702.50 642.16 C694.92,644.85 689.33,644.29 681.00,640.02 C677.31,638.12 638.15,615.52 599.50,592.99 C503.93,537.26 476.74,521.16 473.00,518.04 C470.41,515.89 467.22,511.82 465.50,508.47 L 462.50 502.64 L 462.50 253.50 L 465.29 247.82 C468.69,240.89 472.25,237.67 484.24,230.67 C489.33,227.71 513.08,213.81 537.00,199.81 C560.92,185.80 590.85,168.34 603.50,161.02 C616.15,153.69 638.20,140.85 652.50,132.49 C678.83,117.09 683.42,114.15 688.26,113.64 C689.40,113.52 690.56,113.53 692.02,113.55 C692.62,113.56 693.27,113.57 694.00,113.57 C694.70,113.57 695.33,113.56 695.91,113.56 C697.46,113.54 698.67,113.53 699.86,113.66 C704.62,114.19 709.11,117.06 733.50,131.31 C746.70,139.02 772.35,153.95 790.50,164.49 C824.05,183.97 881.98,217.75 903.19,230.21 C915.17,237.24 918.45,240.19 922.30,247.38 L 924.50 251.50 L 924.50 504.50 L 921.64 509.66 C916.70,518.58 916.66,518.60 870.50,545.21 C863.35,549.33 823.75,572.43 782.50,596.54 C741.25,620.66 705.25,641.19 702.50,642.16 ZM 717.16 351.25 C716.97,359.36 717.09,366.00 717.43,366.00 C718.37,366.00 718.98,365.61 781.00,325.17 C799.97,312.80 828.10,294.59 843.50,284.71 C858.90,274.82 871.85,266.42 872.29,266.03 C873.08,265.32 834.82,242.08 828.50,239.44 C824.41,237.73 822.73,237.67 817.71,239.06 C813.09,240.35 808.52,244.25 771.50,278.49 C725.40,321.12 724.22,322.31 720.64,329.86 C717.63,336.22 717.49,337.13 717.16,351.25 ZM 663.00 361.57 C666.58,363.93 669.84,365.89 670.25,365.93 C671.64,366.06 671.05,343.62 669.51,337.69 C666.69,326.86 664.00,323.17 647.82,307.83 C622.45,283.77 577.69,242.89 574.65,240.98 C570.40,238.32 563.84,237.62 559.62,239.39 C557.67,240.20 546.79,246.41 535.45,253.18 C518.83,263.09 515.14,265.68 516.45,266.49 C520.99,269.30 650.45,353.30 663.00,361.57 ZM 728.28 386.39 C727.94,386.73 748.81,387.00 774.66,387.00 L 821.64 387.00 L 825.21 384.25 C827.17,382.74 836.75,374.75 846.50,366.50 C856.26,358.25 866.42,349.80 869.09,347.73 C871.76,345.65 875.19,341.83 876.72,339.23 L 879.50 334.50 L 879.86 311.75 C880.10,296.21 879.88,289.00 879.16,289.00 C877.81,289.00 863.66,298.04 809.77,333.31 C793.12,344.21 768.11,360.47 754.19,369.45 C740.28,378.43 728.61,386.05 728.28,386.39 ZM 563.40 384.75 L 566.39 387.00 L 613.39 387.00 C639.25,387.00 659.97,386.63 659.45,386.18 C658.93,385.73 652.65,381.61 645.50,377.02 C638.35,372.43 627.55,365.46 621.50,361.53 C615.45,357.60 606.33,351.71 601.24,348.44 C596.15,345.17 580.61,335.08 566.71,326.00 C517.54,293.89 509.90,289.00 508.95,289.00 C508.38,289.00 508.00,297.70 508.00,310.98 C508.00,328.98 508.30,333.70 509.63,337.04 C510.98,340.40 515.63,344.80 535.83,361.81 C549.35,373.19 561.75,383.51 563.40,384.75 ZM 532.00 508.17 C532.00,508.63 549.69,509.00 571.32,509.00 C602.96,509.00 611.60,508.71 615.57,507.53 C620.89,505.95 624.09,502.99 641.55,483.50 C646.48,478.00 653.93,469.67 658.12,465.00 C664.62,457.74 667.85,454.42 669.45,450.45 C671.03,446.52 671.02,441.96 671.00,432.32 C671.00,431.23 671.00,430.08 671.00,428.86 C671.00,417.94 670.70,409.00 670.33,409.00 C669.97,409.00 664.90,412.44 659.08,416.64 C617.21,446.89 562.08,486.31 543.75,499.12 C537.29,503.64 532.00,507.71 532.00,508.17 ZM 771.81 507.42 C775.54,508.56 784.52,508.88 815.58,508.93 C837.08,508.97 854.95,508.72 855.30,508.37 C855.92,507.75 849.23,502.83 804.50,471.01 C792.40,462.40 774.17,449.35 764.00,442.01 C725.01,413.88 718.15,409.00 717.58,409.00 C717.26,409.00 717.00,418.36 717.00,429.81 L 717.00 450.61 L 732.62 468.06 C764.31,503.46 766.69,505.84 771.81,507.42 ZM 543.00 476.25 C543.00,476.66 543.19,477.00 543.42,477.00 C543.66,477.00 552.09,471.29 562.17,464.31 C584.47,448.87 594.78,441.77 619.76,424.68 L 639.01 411.50 L 608.76 411.21 C592.12,411.05 577.10,411.17 575.39,411.48 C567.32,412.93 558.62,422.32 555.93,432.50 C555.35,434.70 552.20,445.27 548.93,456.00 C545.67,466.73 543.00,475.84 543.00,476.25 ZM 830.79 467.70 C837.23,472.21 843.00,475.92 843.61,475.95 C844.62,476.00 842.43,468.08 831.96,433.87 C829.54,425.96 828.20,423.33 824.48,419.19 C817.13,411.02 816.49,410.90 780.27,411.22 L 749.05 411.50 L 784.06 435.50 C803.32,448.70 824.35,463.19 830.79,467.70 Z" fill="#002FA7"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,53 @@
---
name: bugpack-fix-bug
description: "Fix a bug from BugPack by reading its context, locating code, applying fixes, and updating status. Use when: user asks to fix, repair, or resolve a bug. NOT for: just listing bugs (use bugpack-list-bugs) or just viewing bug details (use bugpack-view-bug)."
metadata:
openclaw:
emoji: "\U0001F527"
---
# BugPack - Fix Bug
Read bug context from BugPack, locate the relevant code, apply a fix, and mark the bug as fixed.
## Instructions
1. **Get bug context**: Call `GET http://localhost:3456/api/bugs/:id` to fetch full bug details including description, screenshots, environment, and related files.
2. **Analyze the bug**: Read the description and examine the screenshots to understand what is broken and what the expected behavior should be.
3. **Locate code**: Use the `relatedFiles` array from the bug context to find the relevant source files. If `relatedFiles` is empty, use the `pagePath` and `description` to search the codebase.
4. **Apply fix**: Edit the source code to fix the described issue. Follow the project's existing code style and conventions.
5. **Mark as fixed**: After applying the fix, call `PATCH http://localhost:3456/api/bugs/:id` with:
```json
{ "status": "fixed" }
```
6. **Add fix note** (optional): Call `PATCH http://localhost:3456/api/bugs/:id` with a description update to document what was changed.
## Example
```bash
# Step 1: Get bug context
GET http://localhost:3456/api/bugs/abc-123
# Step 5: Mark as fixed
PATCH http://localhost:3456/api/bugs/abc-123
Content-Type: application/json
{ "status": "fixed" }
```
Response:
```json
{
"ok": true,
"data": {
"id": "abc-123",
"status": "fixed"
}
}
```
@@ -0,0 +1,42 @@
---
name: bugpack-list-bugs
description: "List all tracked bugs from BugPack with status and project filtering. Use when: user asks about bugs, pending issues, bug lists, or wants to see what needs fixing. NOT for: viewing detailed bug context (use bugpack-view-bug) or fixing bugs (use bugpack-fix-bug)."
metadata:
openclaw:
emoji: "\U0001F41B"
---
# BugPack - List Bugs
Query the BugPack local server to list all tracked bugs.
## Instructions
1. Call `GET http://localhost:3456/api/bugs` to fetch all bugs.
- Optional query param: `?project_id=<id>` to filter by project.
2. Parse the JSON response. Each bug has: `id`, `title`, `description`, `status`, `priority`, `project_id`, `created_at`, `updated_at`.
3. Present the list in a readable table format, grouped by status (`open` / `fixed` / `closed`).
4. If no bugs are found, tell the user there are no tracked bugs.
## Example
```
GET http://localhost:3456/api/bugs
```
Response:
```json
{
"ok": true,
"data": [
{
"id": "abc-123",
"title": "Button click not working",
"status": "open",
"priority": "high",
"created_at": "2026-03-15T10:00:00Z"
}
]
}
```
@@ -0,0 +1,57 @@
---
name: bugpack-view-bug
description: "View detailed bug context from BugPack including screenshots, environment info, and related files. Use when: user wants to see bug details, screenshots, or understand a specific bug before fixing. NOT for: listing all bugs (use bugpack-list-bugs) or directly fixing bugs (use bugpack-fix-bug)."
metadata:
openclaw:
emoji: "\U0001F50D"
---
# BugPack - View Bug Details
Fetch full bug context from BugPack, including description, screenshots, environment info, and related files.
## Instructions
1. Call `GET http://localhost:3456/api/bugs/:id` to get the full bug details.
2. The response includes:
- `title`, `description`, `status`, `priority`
- `pagePath` — the page/route where the bug occurs
- `device`, `browser` — environment info
- `relatedFiles` — array of file paths related to the bug
- `screenshots` — array of screenshot objects with `id`, `name`, `original_path`, `annotated_path`
3. Display the bug info in a structured format.
4. If the bug has screenshots, mention them and offer to show annotated versions.
5. If `relatedFiles` are listed, use them to locate relevant source code.
## Example
```
GET http://localhost:3456/api/bugs/abc-123
```
Response:
```json
{
"ok": true,
"data": {
"id": "abc-123",
"title": "Button click not working",
"description": "The submit button on the login page does not respond to clicks",
"status": "open",
"priority": "high",
"pagePath": "/login",
"device": "Desktop",
"browser": "Chrome 120",
"relatedFiles": ["src/pages/Login.tsx", "src/components/SubmitButton.tsx"],
"screenshots": [
{
"id": "ss-001",
"name": "login-bug.png",
"original_path": "/uploads/MyProject/original.png",
"annotated_path": "/uploads/MyProject/annotated.png"
}
]
}
}
```
+83
View File
@@ -0,0 +1,83 @@
---
name: bugpack
description: "BugPack - AI-powered bug tracking and fixing toolkit. List bugs, view bug details with screenshots, and fix bugs automatically. Includes three workflows: list-bugs, view-bug, fix-bug. Requires BugPack server running locally."
metadata:
openclaw:
emoji: "\U0001F4E6"
---
# BugPack
AI-powered bug tracking and fixing toolkit. List, view, and fix bugs from BugPack.
## Prerequisites
Start BugPack server first:
```bash
npx bugpack-mcp
```
## Skill 1: List Bugs
Query all tracked bugs with optional filtering.
### Instructions
1. Call `GET http://localhost:3456/api/bugs` to fetch all bugs.
- Optional: `?project_id=<id>` to filter by project.
2. Each bug has: `id`, `title`, `description`, `status`, `priority`, `project_id`, `created_at`.
3. Present results grouped by status (`pending` / `fixed` / `closed`).
### Example
```
GET http://localhost:3456/api/bugs
```
---
## Skill 2: View Bug Details
Fetch full bug context including screenshots, environment, and related files.
### Instructions
1. Call `GET http://localhost:3456/api/bugs/:id` for full details.
2. Response includes: `title`, `description`, `status`, `priority`, `pagePath`, `device`, `browser`, `relatedFiles`, `screenshots`.
3. Use `relatedFiles` to locate relevant source code.
4. Screenshots have `original_path` and `annotated_path`.
### Example
```
GET http://localhost:3456/api/bugs/abc-123
```
---
## Skill 3: Fix Bug
Read bug context, locate code, apply fix, and update status.
### Instructions
1. **Get context**: `GET http://localhost:3456/api/bugs/:id`
2. **Analyze**: Read description and examine screenshots.
3. **Locate code**: Use `relatedFiles` or search by `pagePath` and `description`.
4. **Apply fix**: Edit source code following project conventions.
5. **Mark fixed**: `PATCH http://localhost:3456/api/bugs/:id` with `{ "status": "fixed" }`
6. **Add note** (optional): Update description to document what was changed.
### Example
```bash
# Get bug context
GET http://localhost:3456/api/bugs/abc-123
# Mark as fixed
PATCH http://localhost:3456/api/bugs/abc-123
Content-Type: application/json
{ "status": "fixed" }
```
+7
View File
@@ -0,0 +1,7 @@
startCommand:
type: stdio
configSchema:
type: object
properties: {}
commandFunction: |-
(config) => ({ command: 'npx', args: ['bugpack-mcp', '--mcp'] })
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useCallback, useState, useRef } from 'react'
import { useStore } from './stores'
import { useKeyboard } from './hooks/useKeyboard'
import { Navbar } from './components/Navbar'
import { Sidebar } from './components/Sidebar'
import { EditorArea } from './components/EditorArea'
import { PropertyPanel } from './components/PropertyPanel'
import { StatusBar } from './components/StatusBar'
import { SettingsModal } from './components/SettingsModal'
import { ShortcutsModal } from './components/ShortcutsModal'
import { EmptyState } from './components/EmptyState'
import { PreviewArea } from './components/PreviewArea'
export default function App() {
const { bugs, selectedBugId, viewMode, settingsOpen, shortcutsOpen, projects, locale, fetchSettings, fetchProjects, createProject, createBug, pasteScreenshot } = useStore()
const [initLoaded, setInitLoaded] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const initRef = useRef(false)
const selectedBug = bugs.find((b) => b.id === selectedBugId)
// Sidebar drag-to-resize
const [sidebarWidth, setSidebarWidth] = useState(240)
const [panelWidth, setPanelWidth] = useState(320)
const isDraggingRef = useRef(false)
const makeResizeHandler = useCallback((setter: (w: number) => void, current: number, min: number, max: number, direction: 'left' | 'right') => {
return (e: React.MouseEvent) => {
e.preventDefault()
isDraggingRef.current = true
const startX = e.clientX
const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return
const delta = ev.clientX - startX
const newWidth = direction === 'left'
? current + delta // Left sidebar: wider on mouse right
: current - delta // Right sidebar: wider on mouse left
setter(Math.max(min, Math.min(max, newWidth)))
}
const onUp = () => {
isDraggingRef.current = false
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
}, [])
useKeyboard()
// 初始化:加载设置(恢复上次项目ID),然后加载项目列表
useEffect(() => {
if (initRef.current) return
initRef.current = true
fetchSettings()
.then(() => fetchProjects())
.then(() => setInitLoaded(true))
.catch((e) => { console.error('初始化失败:', e); setInitLoaded(true) })
}, [])
// Global Ctrl+V paste screenshot (capture phase)
const handlePaste = useCallback((e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
// Check for images
let imageItem: DataTransferItem | null = null
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item && item.type.startsWith('image/')) {
imageItem = item
break
}
}
if (!imageItem) return
// Prevent default when image found
e.preventDefault()
e.stopPropagation()
const blob = imageItem.getAsFile()
if (!blob) return
const reader = new FileReader()
// Capture current selectedBugId to avoid switching during read
const capturedBugId = selectedBugId
reader.onload = async () => {
const dataUrl = reader.result as string
let bugId = capturedBugId
// No bug selected, auto-create one
if (!bugId) {
const newBug = await createBug()
bugId = newBug.id
}
const ssName = locale === 'zh' ? '粘贴截图' : 'Pasted screenshot'
await pasteScreenshot(bugId, dataUrl, ssName)
}
reader.readAsDataURL(blob)
}, [selectedBugId, createBug, pasteScreenshot])
useEffect(() => {
// capture: true ensures capture before all child elements
window.addEventListener('paste', handlePaste, true)
return () => window.removeEventListener('paste', handlePaste, true)
}, [handlePaste])
// Create first project
const handleCreateFirst = async () => {
const name = newProjectName.trim()
if (!name) return
await createProject(name)
setNewProjectName('')
}
// Show blank until data loads to prevent flicker
if (!initLoaded) {
return <div className="h-screen bg-bg-primary" />
}
// Show onboarding page when no projects
if (projects.length === 0) {
return (
<div className="flex flex-col h-screen bg-bg-primary text-text-primary font-sans items-center justify-center">
<div className="text-center max-w-sm">
<div className="flex items-center justify-center mb-6">
<img src="/favicon.svg" alt="BugPack" className="w-14 h-14" />
</div>
<h1 className="text-2xl font-bold mb-2">BugPack</h1>
<p className="text-sm text-text-muted mb-8">
{locale === 'zh' ? '创建你的第一个项目开始使用' : 'Create your first project to get started'}
</p>
<div className="flex gap-2">
<input
autoFocus
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFirst() }}
placeholder={locale === 'zh' ? '输入项目名称' : 'Project name'}
className="flex-1 px-4 py-3 bg-bg-input border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
/>
<button
onClick={handleCreateFirst}
className="px-6 py-3 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors"
>
{locale === 'zh' ? '创建' : 'Create'}
</button>
</div>
</div>
</div>
)
}
return (
<div className="flex flex-col h-screen bg-bg-primary text-text-primary font-sans overflow-hidden">
<Navbar />
<div className="flex flex-1 overflow-hidden">
<Sidebar width={sidebarWidth} />
{/* Left resize handle */}
<div
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors"
onMouseDown={makeResizeHandler(setSidebarWidth, sidebarWidth, 160, 400, 'left')}
/>
<main className="flex-1 overflow-hidden">
{!selectedBug ? (
<EmptyState />
) : viewMode === 'edit' ? (
<EditorArea key={selectedBug.id} bug={selectedBug} />
) : (
<PreviewArea key={selectedBug.id} bug={selectedBug} />
)}
</main>
{selectedBug && viewMode === 'edit' && (
<>
{/* Right resize handle */}
<div
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors"
onMouseDown={makeResizeHandler(setPanelWidth, panelWidth, 240, 600, 'right')}
/>
<PropertyPanel key={selectedBug.id} bug={selectedBug} width={panelWidth} />
</>
)}
</div>
<StatusBar />
{settingsOpen && <SettingsModal />}
{shortcutsOpen && <ShortcutsModal />}
</div>
)
}
+264
View File
@@ -0,0 +1,264 @@
const BASE = '/api'
// Generic request
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Request failed' }))
throw new Error(err.error || `HTTP ${res.status}`)
}
return res.json()
}
// Bug API types
export interface ApiBug {
id: string
number: number
title: string
description: string
status: string
priority: string
page_path: string
device: string
browser: string
related_files?: string
relatedFiles: string[]
screenshots: ApiScreenshot[]
created_at: string
updated_at: string
}
export interface ApiScreenshot {
id: string
url: string
name: string
annotated: boolean
annotations: unknown[]
}
// Project API types
export interface ApiProject {
id: string
name: string
created_at: string
}
// API methods
export const api = {
// Get all bugs (filtered by project)
getBugs: (projectId?: string) =>
request<ApiBug[]>(projectId ? `/bugs?project_id=${projectId}` : '/bugs'),
// Get single bug
getBug: (id: string) => request<ApiBug>(`/bugs/${id}`),
// Create bug
createBug: (data: { title?: string; project_id?: string }) =>
request<ApiBug>('/bugs', {
method: 'POST',
body: JSON.stringify(data),
}),
// Update bug
updateBug: (id: string, data: Record<string, unknown>) =>
request<ApiBug>(`/bugs/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
// Delete bug
deleteBug: (id: string) =>
request<{ ok: boolean }>(`/bugs/${id}`, { method: 'DELETE' }),
// Upload screenshot file
uploadScreenshot: async (bugId: string, file: File, name?: string) => {
const form = new FormData()
form.append('file', file)
if (name) form.append('name', name)
const res = await fetch(`${BASE}/bugs/${bugId}/screenshots`, {
method: 'POST',
body: form,
})
return res.json() as Promise<ApiScreenshot>
},
// Paste screenshot (Base64)
pasteScreenshot: (bugId: string, dataUrl: string, name?: string) =>
request<ApiScreenshot>(`/bugs/${bugId}/screenshots/paste`, {
method: 'POST',
body: JSON.stringify({ dataUrl, name }),
}),
// Rename screenshot
renameScreenshot: (bugId: string, ssId: string, name: string) =>
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
}),
// Mark screenshot as annotated
markScreenshotAnnotated: (bugId: string, ssId: string) =>
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
method: 'PATCH',
body: JSON.stringify({ annotated: true }),
}),
// Save annotation data
saveAnnotations: (bugId: string, ssId: string, annotations: unknown) =>
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
method: 'PATCH',
body: JSON.stringify({ annotations }),
}),
// Save annotated render image (full screenshot with annotations)
saveAnnotatedImage: (bugId: string, ssId: string, dataUrl: string) =>
request<{ ok: boolean; annotatedFilename?: string }>(`/bugs/${bugId}/screenshots/${ssId}/annotated-image`, {
method: 'POST',
body: JSON.stringify({ dataUrl }),
}),
// Reorder screenshots
reorderScreenshots: (bugId: string, order: string[]) =>
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/reorder`, {
method: 'PUT',
body: JSON.stringify({ order }),
}),
// Batch update status
batchUpdateStatus: (ids: string[], status: string) =>
request<{ ok: boolean }>('/bugs/batch/status', {
method: 'PATCH',
body: JSON.stringify({ ids, status }),
}),
// Batch delete
batchDeleteBugs: (ids: string[]) =>
request<{ ok: boolean }>('/bugs/batch/delete', {
method: 'POST',
body: JSON.stringify({ ids }),
}),
// Delete screenshot
deleteScreenshot: (bugId: string, ssId: string) =>
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
method: 'DELETE',
}),
// Get settings
getSettings: () => request<Record<string, string>>('/settings'),
// Save settings
saveSettings: (data: Record<string, string>) =>
request<{ ok: boolean }>('/settings', {
method: 'PUT',
body: JSON.stringify(data),
}),
// Pick directory (server-side native dialog)
pickDirectory: () =>
request<{ path: string }>('/settings/pick-directory', { method: 'POST' }),
// Project management
getProjects: () => request<ApiProject[]>('/projects'),
createProject: (name: string) =>
request<ApiProject>('/projects', {
method: 'POST',
body: JSON.stringify({ name }),
}),
renameProject: (id: string, name: string) =>
request<{ ok: boolean }>(`/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
}),
deleteProject: (id: string) =>
request<{ ok: boolean }>(`/projects/${id}`, { method: 'DELETE' }),
// Export project data
exportProject: (id: string) => `/api/projects/${id}/export`,
// Import project data (ZIP file)
importProject: async (id: string, file: File) => {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${BASE}/projects/${id}/import`, {
method: 'POST',
body: form,
})
return res.json() as Promise<{ ok: boolean; importedCount: number; error?: string }>
},
// TAPD integration
tapd: {
test: (data: { apiUser: string; apiPassword: string; workspaceId?: string }) =>
request<{ ok: boolean; error?: string }>('/tapd/test', { method: 'POST', body: JSON.stringify(data) }),
getWorkspaces: () =>
request<{ ok: boolean; workspaces: any[]; error?: string }>('/tapd/workspaces'),
getBugs: () =>
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/tapd/bugs'),
importBug: (id: string, projectId: string) =>
request<{ ok: boolean; bugId: string; number: number }>(`/tapd/import/${id}`, {
method: 'POST', body: JSON.stringify({ projectId }),
}),
resolve: (id: string) =>
request<{ ok: boolean }>(`/tapd/resolve/${id}`, { method: 'POST' }),
},
// Linear integration
linear: {
test: (data: { token: string }) =>
request<{ ok: boolean; user?: string; error?: string }>('/linear/test', { method: 'POST', body: JSON.stringify(data) }),
getTeams: () =>
request<{ ok: boolean; teams: any[]; error?: string }>('/linear/teams'),
getBugs: () =>
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/linear/bugs'),
importBug: (id: string, projectId: string) =>
request<{ ok: boolean; bugId: string; number: number }>(`/linear/import/${id}`, {
method: 'POST', body: JSON.stringify({ projectId }),
}),
resolve: (id: string) =>
request<{ ok: boolean }>(`/linear/resolve/${id}`, { method: 'POST' }),
},
// Jira integration
jira: {
test: (data: { url: string; email: string; token: string }) =>
request<{ ok: boolean; user?: string; error?: string }>('/jira/test', { method: 'POST', body: JSON.stringify(data) }),
getProjects: () =>
request<{ ok: boolean; projects: any[]; error?: string }>('/jira/projects'),
getBugs: () =>
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/jira/bugs'),
importBug: (key: string, projectId: string) =>
request<{ ok: boolean; bugId: string; number: number }>(`/jira/import/${key}`, {
method: 'POST', body: JSON.stringify({ projectId }),
}),
resolve: (key: string) =>
request<{ ok: boolean }>(`/jira/resolve/${key}`, { method: 'POST' }),
},
// Zentao integration
zentao: {
test: (data: { url: string; httpUser?: string; httpPass?: string; account: string; password: string }) =>
request<{ ok: boolean; error?: string }>('/zentao/test', { method: 'POST', body: JSON.stringify(data) }),
getProducts: () =>
request<{ ok: boolean; products: any[]; error?: string }>('/zentao/products'),
getBugs: () =>
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/zentao/bugs'),
getBug: (id: number) =>
request<{ ok: boolean; bug: any }>(`/zentao/bugs/${id}`),
importBug: (id: number, projectId: string) =>
request<{ ok: boolean; bugId: string; number: number }>(`/zentao/import/${id}`, {
method: 'POST', body: JSON.stringify({ projectId }),
}),
resolve: (id: number, resolution?: string) =>
request<{ ok: boolean }>(`/zentao/resolve/${id}`, {
method: 'POST', body: JSON.stringify({ resolution }),
}),
},
}
@@ -0,0 +1,786 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import * as fabric from 'fabric'
export type AnnotationTool = 'drag' | 'select' | 'rect' | 'arrow' | 'text' | 'number' | 'highlight' | 'pen' | 'mosaic'
interface Props {
imageUrl: string
color: string
tool: AnnotationTool
lineWidth: number
zoom: number
onZoomChange: (zoom: number) => void
onAnnotated?: () => void
initialAnnotations?: unknown[]
onSaveAnnotations?: (canvasJson: unknown, annotatedDataUrl: string | null) => void
}
interface TextInputState {
x: number
y: number
screenX: number
screenY: number
}
// Number counter
let numberCounter = 1
export function AnnotationCanvas({ imageUrl, color, tool, lineWidth, zoom, onZoomChange, onAnnotated, initialAnnotations, onSaveAnnotations }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const fcRef = useRef<fabric.Canvas | null>(null)
const bgImageRef = useRef<fabric.FabricImage | null>(null)
const isDrawingRef = useRef(false)
const startPointRef = useRef<{ x: number; y: number } | null>(null)
const activeShapeRef = useRef<fabric.FabricObject | null>(null)
const undoStackRef = useRef<string[]>([])
const redoStackRef = useRef<string[]>([])
const lastStateRef = useRef<string | null>(null)
const initialStateRef = useRef<string | null>(null)
const isRestoringRef = useRef(false)
const [textInput, setTextInput] = useState<TextInputState | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!canvasRef.current || !containerRef.current) return
const container = containerRef.current
const fc = new fabric.Canvas(canvasRef.current, {
width: container.clientWidth,
height: container.clientHeight,
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim() || '#0A0A0F',
selection: true,
})
fcRef.current = fc
const observer = new ResizeObserver(() => {
fc.setDimensions({
width: container.clientWidth,
height: container.clientHeight,
})
fitImage()
})
observer.observe(container)
const themeObserver = new MutationObserver(() => {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
if (bg) {
fc.backgroundColor = bg
fc.renderAll()
}
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => {
observer.disconnect()
themeObserver.disconnect()
fc.dispose()
fcRef.current = null
}
}, [])
const fitImage = useCallback(() => {
const fc = fcRef.current
const img = bgImageRef.current
if (!fc || !img) return
const canvasW = fc.getWidth()
const canvasH = fc.getHeight()
const imgW = img.width || 1
const imgH = img.height || 1
const scale = Math.min(canvasW / imgW, canvasH / imgH) * 0.85
img.set({
scaleX: scale,
scaleY: scale,
left: (canvasW - imgW * scale) / 2,
top: (canvasH - imgH * scale) / 2,
})
fc.renderAll()
}, [])
useEffect(() => {
const fc = fcRef.current
if (!fc || !imageUrl) return
fabric.FabricImage.fromURL(imageUrl).then((img) => {
if (bgImageRef.current) fc.remove(bgImageRef.current)
img.set({
selectable: false,
evented: false,
hasControls: false,
})
bgImageRef.current = img
fc.insertAt(0, img)
fitImage()
numberCounter = 1
initialStateRef.current = JSON.stringify(fc.toJSON())
if (initialAnnotations && Array.isArray(initialAnnotations) && initialAnnotations.length > 0) {
const savedJson = initialAnnotations[0] as any
if (savedJson && savedJson.objects) {
isRestoringRef.current = true
fc.loadFromJSON(savedJson).catch((err: unknown) => { console.error('Canvas restore failed:', err) }).then(() => {
isRestoringRef.current = false
const objs = fc.getObjects()
if (objs[0]) {
objs[0].selectable = false
objs[0].evented = false
bgImageRef.current = objs[0] as fabric.FabricImage
}
let maxNum = 0
for (const obj of objs) {
if (obj instanceof fabric.Group) {
const groupObjs = obj.getObjects()
for (const child of groupObjs) {
if (child instanceof fabric.Text && /^\d+$/.test(child.text || '')) {
maxNum = Math.max(maxNum, parseInt(child.text || '0'))
}
}
}
}
numberCounter = maxNum + 1
lastStateRef.current = JSON.stringify(fc.toJSON())
undoStackRef.current = []
redoStackRef.current = []
// After restore: re-scale background image + proportionally adjust annotations
const bgImg = bgImageRef.current
if (bgImg) {
const oldScaleX = bgImg.scaleX || 1
const oldScaleY = bgImg.scaleY || 1
const oldLeft = bgImg.left || 0
const oldTop = bgImg.top || 0
// Recalculate scale using fitImage logic
const canvasW = fc.getWidth()
const canvasH = fc.getHeight()
const imgW = bgImg.width || 1
const imgH = bgImg.height || 1
const newScale = Math.min(canvasW / imgW, canvasH / imgH) * 0.85
const newLeft = (canvasW - imgW * newScale) / 2
const newTop = (canvasH - imgH * newScale) / 2
bgImg.set({ scaleX: newScale, scaleY: newScale, left: newLeft, top: newTop })
// Proportionally adjust all annotation objects
const ratioX = newScale / oldScaleX
const ratioY = newScale / oldScaleY
for (const obj of fc.getObjects()) {
if (obj === bgImg) continue
obj.set({
left: (((obj.left || 0) - oldLeft) * ratioX) + newLeft,
top: (((obj.top || 0) - oldTop) * ratioY) + newTop,
scaleX: (obj.scaleX || 1) * ratioX,
scaleY: (obj.scaleY || 1) * ratioY,
})
obj.setCoords()
}
}
fc.renderAll()
})
return
}
}
const initJson = JSON.stringify(fc.toJSON())
initialStateRef.current = initJson
lastStateRef.current = initJson
undoStackRef.current = []
redoStackRef.current = []
})
}, [imageUrl, fitImage])
useEffect(() => {
const fc = fcRef.current
if (!fc) return
fc.setZoom(zoom / 100)
fc.renderAll()
}, [zoom])
// Scroll wheel zoom
useEffect(() => {
const fc = fcRef.current
if (!fc) return
const handleWheel = (opt: fabric.TEvent<WheelEvent>) => {
const e = opt.e
e.preventDefault()
e.stopPropagation()
const delta = e.deltaY
let newZoom = zoom + (delta > 0 ? -10 : 10)
newZoom = Math.max(25, Math.min(300, newZoom))
onZoomChange(newZoom)
}
fc.on('mouse:wheel', handleWheel)
return () => { fc.off('mouse:wheel', handleWheel) }
}, [zoom, onZoomChange])
// Commit pending text input when switching tools
useEffect(() => {
if (textInput) commitTextInput()
}, [tool])
useEffect(() => {
const fc = fcRef.current
if (!fc) return
if (tool === 'drag') {
fc.isDrawingMode = false
fc.selection = false
fc.discardActiveObject()
fc.forEachObject((obj) => {
obj.selectable = false
obj.evented = false
})
fc.defaultCursor = 'grab'
fc.hoverCursor = 'grab'
} else if (tool === 'select') {
fc.isDrawingMode = false
fc.selection = true
fc.defaultCursor = 'default'
fc.hoverCursor = 'move'
fc.forEachObject((obj) => {
if (obj !== bgImageRef.current) {
obj.selectable = true
obj.evented = true
}
})
} else if (tool === 'pen') {
fc.isDrawingMode = true
fc.selection = false
fc.defaultCursor = 'crosshair'
fc.hoverCursor = 'crosshair'
fc.discardActiveObject()
fc.forEachObject((obj) => {
obj.selectable = false
obj.evented = false
})
const brush = new fabric.PencilBrush(fc)
brush.width = lineWidth
brush.color = color
fc.freeDrawingBrush = brush
} else if (tool === 'mosaic') {
fc.isDrawingMode = true
fc.selection = false
fc.defaultCursor = 'crosshair'
fc.hoverCursor = 'crosshair'
fc.discardActiveObject()
fc.forEachObject((obj) => {
obj.selectable = false
obj.evented = false
})
const brush = new fabric.PencilBrush(fc)
brush.width = lineWidth * 6
brush.color = 'rgba(128,128,128,0.35)'
fc.freeDrawingBrush = brush
} else {
fc.isDrawingMode = false
fc.selection = false
fc.defaultCursor = 'crosshair'
fc.hoverCursor = 'crosshair'
fc.discardActiveObject()
fc.forEachObject((obj) => {
obj.selectable = false
obj.evented = false
})
}
fc.renderAll()
}, [tool, lineWidth, color])
// Drag panning
useEffect(() => {
const fc = fcRef.current
if (!fc || tool !== 'drag') return
let isDragging = false
let lastX = 0
let lastY = 0
const handleDown = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
isDragging = true
const e = opt.e as MouseEvent
lastX = e.clientX
lastY = e.clientY
fc.defaultCursor = 'grabbing'
fc.hoverCursor = 'grabbing'
}
const handleMove = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
if (!isDragging) return
const e = opt.e as MouseEvent
const vpt = fc.viewportTransform!
vpt[4] += e.clientX - lastX
vpt[5] += e.clientY - lastY
lastX = e.clientX
lastY = e.clientY
fc.requestRenderAll()
}
const handleUp = () => {
isDragging = false
fc.defaultCursor = 'grab'
fc.hoverCursor = 'grab'
}
fc.on('mouse:down', handleDown)
fc.on('mouse:move', handleMove)
fc.on('mouse:up', handleUp)
return () => {
fc.off('mouse:down', handleDown)
fc.off('mouse:move', handleMove)
fc.off('mouse:up', handleUp)
}
}, [tool])
// Drawing tools (rect/arrow/text/number/highlight)
useEffect(() => {
const fc = fcRef.current
if (!fc) return
if (tool === 'drag' || tool === 'select' || tool === 'pen' || tool === 'mosaic') return
const handleMouseDown = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
const pointer = fc.getScenePoint(opt.e)
isDrawingRef.current = true
startPointRef.current = { x: pointer.x, y: pointer.y }
if (tool === 'text') {
const target = fc.findTarget(opt.e)
if (target && target !== bgImageRef.current) {
fc.setActiveObject(target)
fc.renderAll()
isDrawingRef.current = false
return
}
const e = opt.e as MouseEvent
const container = containerRef.current
if (container) {
const rect = container.getBoundingClientRect()
setTextInput({
x: pointer.x,
y: pointer.y,
screenX: e.clientX - rect.left,
screenY: e.clientY - rect.top,
})
setTimeout(() => textareaRef.current?.focus(), 0)
}
isDrawingRef.current = false
return
}
if (tool === 'number') {
const num = numberCounter++
const circle = new fabric.Circle({
radius: 14,
fill: color,
originX: 'center',
originY: 'center',
})
const text = new fabric.Text(String(num), {
fontSize: 14,
fill: '#FFFFFF',
fontWeight: 'bold',
fontFamily: 'sans-serif',
originX: 'center',
originY: 'center',
})
const group = new fabric.Group([circle, text], {
left: pointer.x - 14,
top: pointer.y - 14,
})
fc.add(group)
isDrawingRef.current = false
return
}
if (tool === 'rect' || tool === 'highlight') {
const rect = new fabric.Rect({
left: pointer.x,
top: pointer.y,
width: 0,
height: 0,
fill: tool === 'highlight' ? `${color}4D` : 'transparent',
stroke: tool === 'highlight' ? 'transparent' : color,
strokeWidth: tool === 'highlight' ? 0 : lineWidth,
})
fc.add(rect)
activeShapeRef.current = rect
}
if (tool === 'arrow') {
const line = new fabric.Line([pointer.x, pointer.y, pointer.x, pointer.y], {
stroke: color,
strokeWidth: lineWidth,
selectable: false,
})
fc.add(line)
activeShapeRef.current = line
}
}
const handleMouseMove = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
if (!isDrawingRef.current || !startPointRef.current) return
const pointer = fc.getScenePoint(opt.e)
const start = startPointRef.current
if ((tool === 'rect' || tool === 'highlight') && activeShapeRef.current) {
const rect = activeShapeRef.current as fabric.Rect
const w = pointer.x - start.x
const h = pointer.y - start.y
rect.set({
left: w > 0 ? start.x : pointer.x,
top: h > 0 ? start.y : pointer.y,
width: Math.abs(w),
height: Math.abs(h),
})
fc.renderAll()
}
if (tool === 'arrow' && activeShapeRef.current) {
const line = activeShapeRef.current as fabric.Line
line.set({ x2: pointer.x, y2: pointer.y })
fc.renderAll()
}
}
const handleMouseUp = () => {
if (!isDrawingRef.current) return
isDrawingRef.current = false
// After line complete, add arrowhead and group
if (tool === 'arrow' && activeShapeRef.current) {
const line = activeShapeRef.current as fabric.Line
const x1 = line.x1 ?? 0, y1 = line.y1 ?? 0
const x2 = line.x2 ?? 0, y2 = line.y2 ?? 0
const angle = Math.atan2(y2 - y1, x2 - x1)
const headLen = 12
const head = new fabric.Polygon([
{ x: x2, y: y2 },
{ x: x2 - headLen * Math.cos(angle - Math.PI / 6), y: y2 - headLen * Math.sin(angle - Math.PI / 6) },
{ x: x2 - headLen * Math.cos(angle + Math.PI / 6), y: y2 - headLen * Math.sin(angle + Math.PI / 6) },
], {
fill: color,
selectable: false,
})
fc.remove(line)
const group = new fabric.Group([line, head], { selectable: true })
fc.add(group)
}
activeShapeRef.current = null
startPointRef.current = null
fc.renderAll()
}
fc.on('mouse:down', handleMouseDown)
fc.on('mouse:move', handleMouseMove)
fc.on('mouse:up', handleMouseUp)
return () => {
fc.off('mouse:down', handleMouseDown)
fc.off('mouse:move', handleMouseMove)
fc.off('mouse:up', handleMouseUp)
}
}, [tool, color, lineWidth])
// Mosaic brush: pixelate brush area after path complete
useEffect(() => {
const fc = fcRef.current
if (!fc || tool !== 'mosaic') return
const brushWidth = lineWidth * 6
const handlePathCreated = (opt: { path: fabric.Path }) => {
if (!bgImageRef.current) return
const drawnPath = opt.path
const bound = drawnPath.getBoundingRect()
const bgImg = bgImageRef.current
const imgEl = bgImg.getElement() as HTMLImageElement
const bgScale = bgImg.scaleX ?? 1
const bgLeft = bgImg.left ?? 0
const bgTop = bgImg.top ?? 0
const outW = Math.round(bound.width)
const outH = Math.round(bound.height)
if (outW < 2 || outH < 2) { fc.remove(drawnPath); return }
// Map coordinates to original image pixels
const srcX = Math.max(0, (bound.left - bgLeft) / bgScale)
const srcY = Math.max(0, (bound.top - bgTop) / bgScale)
const srcW = Math.min(imgEl.naturalWidth - srcX, bound.width / bgScale)
const srcH = Math.min(imgEl.naturalHeight - srcY, bound.height / bgScale)
if (srcW < 2 || srcH < 2) { fc.remove(drawnPath); return }
// Generate pixelated image
const pixelSize = 10
const smallW = Math.max(1, Math.ceil(srcW / pixelSize))
const smallH = Math.max(1, Math.ceil(srcH / pixelSize))
const offSmall = document.createElement('canvas')
offSmall.width = smallW
offSmall.height = smallH
const ctxSmall = offSmall.getContext('2d')!
ctxSmall.imageSmoothingEnabled = true
ctxSmall.drawImage(imgEl, srcX, srcY, srcW, srcH, 0, 0, smallW, smallH)
// Offscreen compositing: brush path mask + source-in mosaic overlay
const offResult = document.createElement('canvas')
offResult.width = outW
offResult.height = outH
const ctx = offResult.getContext('2d')!
ctx.strokeStyle = '#000'
ctx.lineWidth = brushWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
const pathData = drawnPath.path
if (Array.isArray(pathData)) {
ctx.beginPath()
for (const seg of pathData) {
const cmd = seg[0]
if (cmd === 'M') ctx.moveTo(seg[1] - bound.left, seg[2] - bound.top)
else if (cmd === 'L') ctx.lineTo(seg[1] - bound.left, seg[2] - bound.top)
else if (cmd === 'Q') ctx.quadraticCurveTo(seg[1] - bound.left, seg[2] - bound.top, seg[3] - bound.left, seg[4] - bound.top)
else if (cmd === 'C') ctx.bezierCurveTo(seg[1] - bound.left, seg[2] - bound.top, seg[3] - bound.left, seg[4] - bound.top, seg[5] - bound.left, seg[6] - bound.top)
}
ctx.stroke()
}
ctx.globalCompositeOperation = 'source-in'
ctx.imageSmoothingEnabled = false
ctx.drawImage(offSmall, 0, 0, smallW, smallH, 0, 0, outW, outH)
// Replace path with composited image, release offscreen canvas
fc.remove(drawnPath)
const dataUrl = offResult.toDataURL('image/png')
offSmall.width = 0
offSmall.height = 0
offResult.width = 0
offResult.height = 0
fabric.FabricImage.fromURL(dataUrl).then((mosaicImg) => {
mosaicImg.set({
left: bound.left,
top: bound.top,
selectable: true,
})
fc.add(mosaicImg)
fc.renderAll()
})
}
fc.on('path:created', handlePathCreated as any)
return () => { fc.off('path:created', handlePathCreated as any) }
}, [tool, lineWidth])
// Save state to undo stack
const saveState = useCallback(() => {
if (isRestoringRef.current) return
const fc = fcRef.current
if (!fc) return
if (lastStateRef.current) {
undoStackRef.current.push(lastStateRef.current)
}
redoStackRef.current = []
const currentJson = fc.toJSON()
lastStateRef.current = JSON.stringify(currentJson)
onAnnotated?.()
const dataUrl = fc.toDataURL({ format: 'png', multiplier: 1 })
onSaveAnnotations?.(currentJson, dataUrl)
}, [onAnnotated, onSaveAnnotations])
useEffect(() => {
const fc = fcRef.current
if (!fc) return
const handler = () => saveState()
fc.on('object:added', handler)
fc.on('object:modified', handler)
fc.on('object:removed', handler)
return () => {
fc.off('object:added', handler)
fc.off('object:modified', handler)
fc.off('object:removed', handler)
}
}, [saveState])
const fixBgAfterRestore = useCallback(() => {
const fc = fcRef.current
if (!fc) return
const objs = fc.getObjects()
if (objs[0]) {
objs[0].selectable = false
objs[0].evented = false
bgImageRef.current = objs[0] as fabric.FabricImage
}
fc.renderAll()
}, [])
const performUndo = useCallback(() => {
const fc = fcRef.current
if (!fc || undoStackRef.current.length === 0) return
redoStackRef.current.push(lastStateRef.current || JSON.stringify(fc.toJSON()))
const prev = undoStackRef.current.pop()!
lastStateRef.current = prev
isRestoringRef.current = true
fc.loadFromJSON(prev).then(() => {
isRestoringRef.current = false
fixBgAfterRestore()
}).catch((err: unknown) => { console.error('Undo failed:', err); isRestoringRef.current = false })
}, [fixBgAfterRestore])
const performRedo = useCallback(() => {
const fc = fcRef.current
if (!fc || redoStackRef.current.length === 0) return
undoStackRef.current.push(lastStateRef.current || JSON.stringify(fc.toJSON()))
const next = redoStackRef.current.pop()!
lastStateRef.current = next
isRestoringRef.current = true
fc.loadFromJSON(next).then(() => {
isRestoringRef.current = false
fixBgAfterRestore()
}).catch((err: unknown) => { console.error('Redo failed:', err); isRestoringRef.current = false })
}, [fixBgAfterRestore])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const fc = fcRef.current
if (!fc) return
if (textareaRef.current && document.activeElement === textareaRef.current) return
if (e.key === 'Delete' || e.key === 'Backspace') {
const active = fc.getActiveObject()
if (active && active !== bgImageRef.current) {
if (active instanceof fabric.Textbox && active.isEditing) return
fc.remove(active)
fc.renderAll()
}
return
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault()
performUndo()
return
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Z' && e.shiftKey) {
e.preventDefault()
performRedo()
return
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [performUndo, performRedo])
// Reset annotations, push current state to undo stack
const performReset = useCallback(() => {
const fc = fcRef.current
if (!fc || !initialStateRef.current) return
undoStackRef.current.push(lastStateRef.current || JSON.stringify(fc.toJSON()))
redoStackRef.current = []
lastStateRef.current = initialStateRef.current
isRestoringRef.current = true
numberCounter = 1
fc.loadFromJSON(initialStateRef.current).then(() => {
isRestoringRef.current = false
fixBgAfterRestore()
onSaveAnnotations?.(null, null)
}).catch((err: unknown) => { console.error('Reset failed:', err); isRestoringRef.current = false })
}, [fixBgAfterRestore, onSaveAnnotations])
const commitTextInput = useCallback(() => {
const fc = fcRef.current
const textarea = textareaRef.current
if (!fc || !textarea || !textInput) return
const val = textarea.value.trim()
if (val) {
const fontSize = 16
const text = new fabric.Text(val, {
left: textInput.x,
top: textInput.y,
fontSize,
fill: color,
fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif',
shadow: new fabric.Shadow({ color: 'rgba(0,0,0,0.8)', blur: 3, offsetX: 1, offsetY: 1 }),
})
fc.add(text)
fc.renderAll()
}
setTextInput(null)
}, [textInput, color])
const cancelTextInput = useCallback(() => {
setTextInput(null)
}, [])
// Fit to window: reset zoom + viewport + refit image
const handleFitWindow = useCallback(() => {
const fc = fcRef.current
if (!fc) return
fc.viewportTransform = [1, 0, 0, 1, 0, 0]
fc.setZoom(1)
fitImage()
onZoomChange(100)
}, [fitImage, onZoomChange])
// Listen to toolbar events
useEffect(() => {
const handleUndo = () => performUndo()
const handleRedo = () => performRedo()
const handleReset = () => performReset()
const handleFit = () => handleFitWindow()
window.addEventListener('bugpack:undo', handleUndo)
window.addEventListener('bugpack:redo', handleRedo)
window.addEventListener('bugpack:reset', handleReset)
window.addEventListener('bugpack:fitWindow', handleFit)
return () => {
window.removeEventListener('bugpack:undo', handleUndo)
window.removeEventListener('bugpack:redo', handleRedo)
window.removeEventListener('bugpack:reset', handleReset)
window.removeEventListener('bugpack:fitWindow', handleFit)
}
}, [performUndo, performRedo, performReset, handleFitWindow])
return (
<div ref={containerRef} className="w-full h-full relative">
<canvas ref={canvasRef} />
{textInput && (
<textarea
ref={textareaRef}
className="absolute z-30 outline-none resize-none rounded"
style={{
left: textInput.screenX,
top: textInput.screenY,
minWidth: 120,
minHeight: 32,
fontSize: 16,
lineHeight: '1.4',
padding: '4px 6px',
color: color,
caretColor: color,
background: 'rgba(0,0,0,0.5)',
border: `2px solid ${color}`,
fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif',
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
}}
placeholder="Enter text..."
onBlur={commitTextInput}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); cancelTextInput() }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitTextInput() }
e.stopPropagation()
}}
/>
)}
</div>
)
}
@@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react'
import { AlertTriangle } from 'lucide-react'
interface Props {
open: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({ open, title, message, confirmText, cancelText, onConfirm, onCancel }: Props) {
const confirmRef = useRef<HTMLButtonElement>(null)
// Focus confirm button on open
useEffect(() => {
if (open) confirmRef.current?.focus()
}, [open])
// Close on ESC
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, onCancel])
if (!open) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
<div className="w-[360px] bg-bg-card border border-border rounded-xl shadow-2xl p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-red-500/10 rounded-lg shrink-0">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-text-primary mb-1">{title}</h3>
<p className="text-xs text-text-secondary leading-relaxed">{message}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
>
{cancelText || 'Cancel'}
</button>
<button
ref={confirmRef}
onClick={onConfirm}
className="px-4 py-2 text-sm text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
>
{confirmText || 'Confirm'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,569 @@
import { useState, useCallback, useRef, useEffect, useMemo, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from 'react'
import { useStore, type Bug } from '../stores'
import { api } from '../api'
import { AnnotationCanvas, type AnnotationTool } from './AnnotationCanvas'
import { ConfirmDialog } from './ConfirmDialog'
import {
Hand,
MousePointer2,
Square,
MoveRight,
Type,
Hash,
Highlighter,
Pencil,
Undo2,
Redo2,
RotateCcw,
Minus,
Plus,
Maximize2,
Clipboard,
Plus as PlusIcon,
X,
ImageIcon,
Columns2,
} from 'lucide-react'
// Mosaic pixel icon
function MosaicIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
<rect x="0" y="0" width="4" height="4" />
<rect x="8" y="0" width="4" height="4" />
<rect x="4" y="4" width="4" height="4" />
<rect x="12" y="4" width="4" height="4" />
<rect x="0" y="8" width="4" height="4" />
<rect x="8" y="8" width="4" height="4" />
<rect x="4" y="12" width="4" height="4" />
<rect x="12" y="12" width="4" height="4" />
</svg>
)
}
const toolColors = ['#EF4444', '#F59E0B', '#22C55E', '#3B82F6']
// Clamp index within bounds
function clampIndex(idx: number, length: number) {
return Math.max(0, Math.min(idx, length - 1))
}
export function EditorArea({ bug }: { bug: Bug }) {
const { t, locale, uploadScreenshot, deleteScreenshot, renameScreenshot, updateScreenshotAnnotated, saveAnnotations, reorderScreenshots, compareMode, setCompareMode, compareLeft, setCompareLeft, compareRight, setCompareRight } = useStore()
const zh = locale === 'zh'
// 动态翻译默认截图名
const displayName = (name: string) => {
const m = name.match(/^(Screenshot|截图)\s*(\d+)$/)
if (m) return zh ? `截图 ${m[2]}` : `Screenshot ${m[2]}`
if (name === '粘贴截图' || name === 'Pasted screenshot') return zh ? '粘贴截图' : 'Pasted screenshot'
return name
}
const [editingNameId, setEditingNameId] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState<AnnotationTool>('drag')
const [activeColor, setActiveColor] = useState('#EF4444')
const [activeLineWidth, setActiveLineWidth] = useState(2)
const [zoom, setZoom] = useState(100)
const [selectedScreenshot, setSelectedScreenshot] = useState(0)
const [dragOver, setDragOver] = useState(false)
const [resetConfirm, setResetConfirm] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dragItemRef = useRef<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; imageUrl: string } | null>(null)
const [copyToast, setCopyToast] = useState<string | null>(null)
const copyImageToClipboard = useCallback(async (imageUrl: string) => {
try {
const res = await fetch(imageUrl)
const blob = await res.blob()
const pngBlob = blob.type === 'image/png' ? blob : await new Promise<Blob>((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
canvas.toBlob((b) => resolve(b!), 'image/png')
}
img.src = imageUrl
})
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
setCopyToast(t.editor.copySuccess)
} catch {
setCopyToast(t.editor.copyFail)
}
setTimeout(() => setCopyToast(null), 1500)
setContextMenu(null)
}, [t])
const handleContextMenu = useCallback((e: ReactMouseEvent, imageUrl: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, imageUrl })
}, [])
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
window.addEventListener('click', close)
window.addEventListener('contextmenu', close)
return () => {
window.removeEventListener('click', close)
window.removeEventListener('contextmenu', close)
}
}, [contextMenu])
// Reset screenshot index when bug changes
useEffect(() => {
setSelectedScreenshot(0)
setCompareMode(false)
setCompareLeft(0)
setCompareRight(1)
setZoom(100)
}, [bug.id])
// Cleanup debounce timer on unmount
useEffect(() => {
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [])
// Listen for keyboard tool switching
useEffect(() => {
const handler = (e: Event) => {
const tool = (e as CustomEvent).detail as AnnotationTool
setActiveTool(tool)
}
window.addEventListener('bugpack:tool', handler)
return () => window.removeEventListener('bugpack:tool', handler)
}, [])
const hasScreenshots = bug.screenshots.length > 0
const safeIdx = clampIndex(selectedScreenshot, bug.screenshots.length)
const currentSS = hasScreenshots ? bug.screenshots[safeIdx] : undefined
const safeCompareLeft = clampIndex(compareLeft, bug.screenshots.length)
const safeCompareRight = clampIndex(compareRight, bug.screenshots.length)
// Debounced save of annotation data + annotated render image
const handleSaveAnnotations = useMemo(() => {
return (ssId: string) => (canvasJson: unknown, annotatedDataUrl: string | null) => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => {
saveAnnotations(bug.id, ssId, [canvasJson])
if (annotatedDataUrl) {
api.saveAnnotatedImage(bug.id, ssId, annotatedDataUrl).catch(() => {})
}
}, 800)
}
}, [bug.id, saveAnnotations])
const tools: { key: AnnotationTool; icon: any; label: string }[] = [
{ key: 'drag', icon: Hand, label: t.editor.tools.drag },
{ key: 'select', icon: MousePointer2, label: t.editor.tools.select },
{ key: 'rect', icon: Square, label: t.editor.tools.rect },
{ key: 'arrow', icon: MoveRight, label: t.editor.tools.arrow },
{ key: 'text', icon: Type, label: t.editor.tools.text },
{ key: 'number', icon: Hash, label: t.editor.tools.number },
{ key: 'highlight', icon: Highlighter, label: t.editor.tools.highlight },
{ key: 'pen', icon: Pencil, label: t.editor.tools.pen },
{ key: 'mosaic', icon: MosaicIcon, label: t.editor.tools.mosaic },
]
// Drag-and-drop upload (max 10, sequential to avoid concurrency)
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')).slice(0, 10)
for (const file of files) {
try {
await uploadScreenshot(bug.id, file, file.name)
} catch (err) {
console.error('Upload failed:', err)
}
}
}, [bug.id, uploadScreenshot])
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
for (const file of files) {
await uploadScreenshot(bug.id, file, file.name)
}
e.target.value = ''
}, [bug.id, uploadScreenshot])
return (
<div className="flex flex-col h-full">
<div className="h-12 bg-bg-input border-b border-border flex items-center px-4 shrink-0">
<div className="flex items-center gap-0.5 bg-bg-primary/60 rounded-lg p-1">
{tools.map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setActiveTool(key)}
className={`p-2 rounded-md transition-colors ${
activeTool === key
? 'bg-bg-card text-accent shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
title={label}
>
<Icon className="w-4 h-4" />
</button>
))}
</div>
<div className="w-px h-6 bg-border mx-3" />
<div className="flex items-center gap-1.5">
{toolColors.map((color) => (
<button
key={color}
onClick={() => setActiveColor(color)}
className={`w-5 h-5 rounded-full transition-all ${
activeColor === color ? 'ring-2 ring-white/40 ring-offset-1 ring-offset-bg-input' : 'hover:ring-1 hover:ring-white/20'
}`}
style={{ backgroundColor: color }}
/>
))}
{/* Custom color picker */}
<label
className={`w-5 h-5 rounded-full cursor-pointer transition-all overflow-hidden relative ${
!toolColors.includes(activeColor) ? 'ring-2 ring-white/40 ring-offset-1 ring-offset-bg-input' : 'hover:ring-1 hover:ring-white/20'
}`}
style={{ background: !toolColors.includes(activeColor) ? activeColor : `conic-gradient(red, yellow, lime, aqua, blue, magenta, red)` }}
>
<input
type="color"
value={activeColor}
onChange={(e) => setActiveColor(e.target.value)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</label>
</div>
<div className="w-px h-6 bg-border mx-3" />
<div className="flex items-center gap-1">
<button
onClick={() => window.dispatchEvent(new Event('bugpack:undo'))}
className="p-2 rounded-md text-text-muted hover:text-text-secondary hover:bg-bg-hover transition-colors"
title={t.editor.tools.undo}
>
<Undo2 className="w-4 h-4" />
</button>
<button
onClick={() => window.dispatchEvent(new Event('bugpack:redo'))}
className="p-2 rounded-md text-text-muted hover:text-text-secondary hover:bg-bg-hover transition-colors"
title={t.editor.tools.redo}
>
<Redo2 className="w-4 h-4" />
</button>
<button
onClick={() => setResetConfirm(true)}
className="p-2 rounded-md text-text-muted hover:text-red-400 hover:bg-bg-hover transition-colors"
title={t.editor.tools.reset}
>
<RotateCcw className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-border mx-1" />
<div className="flex items-center gap-1.5">
{([{ val: 1, size: 4 }, { val: 2, size: 7 }, { val: 4, size: 10 }]).map(({ val, size }) => (
<button
key={val}
onClick={() => setActiveLineWidth(val)}
className={`w-6 h-6 flex items-center justify-center rounded-md transition-colors ${
activeLineWidth === val ? 'bg-accent/20' : 'hover:bg-bg-hover'
}`}
>
<span
className={`block rounded-full ${activeLineWidth === val ? 'bg-accent' : 'bg-text-muted'}`}
style={{ width: size, height: size }}
/>
</button>
))}
</div>
{bug.screenshots.length >= 1 && (
<>
<div className="w-px h-5 bg-border mx-1" />
<button
onClick={() => {
setCompareMode(!compareMode)
if (!compareMode) {
setCompareLeft(selectedScreenshot)
setCompareRight(selectedScreenshot === 0 ? 1 : 0)
}
}}
className={`p-2 rounded-md transition-colors ${
compareMode ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
}`}
title={t.editor.compare}
>
<Columns2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
<div
className={`flex-1 relative overflow-hidden bg-bg-primary ${dragOver ? 'drop-active' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onContextMenu={(e) => {
if (currentSS?.url) handleContextMenu(e as unknown as ReactMouseEvent, currentSS.url)
}}
>
{dragOver && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-accent/5 border-2 border-dashed border-accent rounded-lg">
<p className="text-accent text-lg font-medium">{t.editor.emptySubtitle}</p>
</div>
)}
{hasScreenshots && currentSS && compareMode ? (
/* Compare mode: two screenshots side by side */
<div className="absolute inset-0 flex">
<div className="flex-1 flex flex-col border-r border-border">
<div className="text-center py-1.5 bg-bg-input border-b border-border">
<span className="text-xs text-text-muted">{t.editor.compareLeft}</span>
<select
value={safeCompareLeft}
onChange={(e) => setCompareLeft(Number(e.target.value))}
className="ml-2 text-xs bg-bg-card border border-border rounded px-1 py-0.5 text-text-primary"
>
{bug.screenshots.map((ss, i) => (
<option key={ss.id} value={i}>{displayName(ss.name)}</option>
))}
</select>
</div>
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
{bug.screenshots[safeCompareLeft] ? (
<img src={bug.screenshots[safeCompareLeft].url} alt={displayName(bug.screenshots[safeCompareLeft].name)} className="max-w-full max-h-full object-contain rounded" />
) : (
<p className="text-text-muted text-sm">{t.editor.emptySubtitle}</p>
)}
</div>
</div>
<div className="flex-1 flex flex-col">
<div className="text-center py-1.5 bg-bg-input border-b border-border">
<span className="text-xs text-text-muted">{t.editor.compareRight}</span>
<select
value={safeCompareRight}
onChange={(e) => setCompareRight(Number(e.target.value))}
className="ml-2 text-xs bg-bg-card border border-border rounded px-1 py-0.5 text-text-primary"
>
{bug.screenshots.map((ss, i) => (
<option key={ss.id} value={i}>{displayName(ss.name)}</option>
))}
</select>
</div>
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
{bug.screenshots.length >= 2 && bug.screenshots[safeCompareRight] ? (
<img src={bug.screenshots[safeCompareRight].url} alt={bug.screenshots[safeCompareRight].name} className="max-w-full max-h-full object-contain rounded" />
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="flex flex-col items-center justify-center gap-2 text-text-muted hover:text-accent transition-colors border-2 border-dashed border-border hover:border-accent/50 rounded-xl px-12 py-8"
>
<PlusIcon className="w-8 h-8" />
<span className="text-sm">{zh ? '上传期望效果图' : 'Upload expected image'}</span>
</button>
)}
</div>
</div>
</div>
) : hasScreenshots && currentSS ? (
<>
<AnnotationCanvas
key={currentSS.id}
imageUrl={currentSS.url}
color={activeColor}
tool={activeTool}
lineWidth={activeLineWidth}
zoom={zoom}
onZoomChange={setZoom}
initialAnnotations={currentSS.annotations}
onSaveAnnotations={handleSaveAnnotations(currentSS.id)}
onAnnotated={() => {
if (currentSS && !currentSS.annotated) {
updateScreenshotAnnotated(bug.id, currentSS.id)
}
}}
/>
<div className="absolute bottom-4 right-4 flex items-center gap-1 bg-bg-card/90 backdrop-blur-sm rounded-lg px-2 py-1 border border-border z-10">
<button onClick={() => setZoom(Math.max(25, zoom - 25))} className="p-1 text-text-muted hover:text-text-secondary">
<Minus className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-text-secondary w-10 text-center">{zoom}%</span>
<button onClick={() => setZoom(Math.min(200, zoom + 25))} className="p-1 text-text-muted hover:text-text-secondary">
<Plus className="w-3.5 h-3.5" />
</button>
<div className="w-px h-4 bg-border mx-1" />
<button onClick={() => { window.dispatchEvent(new Event('bugpack:fitWindow')) }} className="p-1 text-text-muted hover:text-text-secondary" title={t.editor.fitWindow}>
<Maximize2 className="w-3.5 h-3.5" />
</button>
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center border-2 border-dashed border-border rounded-2xl px-16 py-12">
<Clipboard className="w-12 h-12 text-text-muted mx-auto mb-4" />
<p className="text-lg text-text-secondary mb-1">{t.editor.emptyTitle}</p>
<p className="text-sm text-text-muted mb-3">{t.editor.emptySubtitle}</p>
<p className="text-xs text-text-muted">{t.editor.emptyFormat}</p>
</div>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.html,.md,.json,.xml,.zip"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<div className="h-[180px] border-t border-border bg-bg-sidebar shrink-0">
<div className="flex items-center justify-between px-4 py-2">
<span className="text-sm text-text-secondary">
{t.evidence.title} ({bug.screenshots.length})
</span>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 text-xs text-accent hover:text-accent-hover transition-colors"
>
<PlusIcon className="w-3 h-3" />
{t.evidence.addFile}
</button>
</div>
<div className="flex gap-3 px-4 pb-3 overflow-x-auto">
{bug.screenshots.map((ss, i) => (
<button
key={ss.id}
draggable
onClick={() => setSelectedScreenshot(i)}
onDragStart={() => { dragItemRef.current = i }}
onDragOver={(e) => { e.preventDefault(); setDragOverIndex(i) }}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(e) => {
e.preventDefault()
setDragOverIndex(null)
const from = dragItemRef.current
if (from === null || from === i) return
const newOrder = [...bug.screenshots.map(s => s.id)]
const moved = newOrder.splice(from, 1)[0]
if (moved) newOrder.splice(i, 0, moved)
reorderScreenshots(bug.id, newOrder)
setSelectedScreenshot(i)
dragItemRef.current = null
}}
onDragEnd={() => { dragItemRef.current = null; setDragOverIndex(null) }}
className={`shrink-0 group relative transition-transform ${dragOverIndex === i ? 'scale-105 ring-2 ring-accent' : ''}`}
>
<div
className={`w-[120px] h-[90px] rounded-lg overflow-hidden border-2 transition-colors ${
selectedScreenshot === i ? 'border-accent' : 'border-border hover:border-border'
}`}
>
{ss.url ? (
<img
src={ss.url}
alt={ss.name}
className="w-full h-full object-cover"
onContextMenu={(e) => handleContextMenu(e, ss.url)}
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-bg-input to-bg-card flex items-center justify-center">
<ImageIcon className="w-6 h-6 text-text-muted/50" />
</div>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); deleteScreenshot(bug.id, ss.id) }}
className="absolute top-1 right-1 w-5 h-5 bg-red-500 rounded-full items-center justify-center text-white hidden group-hover:flex z-10"
>
<X className="w-3 h-3" />
</button>
{editingNameId === ss.id ? (
<input
autoFocus
defaultValue={ss.name}
className="text-[11px] text-text-primary mt-1.5 text-center w-[120px] bg-bg-input border border-accent rounded px-1 py-0.5 outline-none"
onBlur={(e) => {
const val = e.target.value.trim()
if (val && val !== ss.name) renameScreenshot(bug.id, ss.id, val)
setEditingNameId(null)
}}
onKeyDown={(e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
if (e.key === 'Escape') setEditingNameId(null)
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<p
className="text-[11px] text-text-secondary mt-1.5 text-center truncate w-[120px] cursor-text"
onDoubleClick={(e) => { e.stopPropagation(); setEditingNameId(ss.id) }}
>
{displayName(ss.name)}
</p>
)}
</button>
))}
<button
onClick={() => fileInputRef.current?.click()}
className="shrink-0 w-[120px] h-[90px] rounded-lg border-2 border-dashed border-border hover:border-accent/50 flex flex-col items-center justify-center text-text-muted hover:text-accent transition-colors"
>
<PlusIcon className="w-6 h-6 mb-1" />
<span className="text-[11px]">{t.evidence.addFile}</span>
</button>
</div>
</div>
<ConfirmDialog
open={resetConfirm}
title={zh ? '重置标注' : 'Reset Annotations'}
message={zh
? `确定清除「${currentSS?.name || '当前截图'}」上的所有标注?此操作可通过撤销恢复。`
: `Clear all annotations on "${currentSS?.name || 'current screenshot'}"? You can undo this action.`}
confirmText={zh ? '确认重置' : 'Reset'}
cancelText={zh ? '取消' : 'Cancel'}
onConfirm={() => {
window.dispatchEvent(new Event('bugpack:reset'))
setResetConfirm(false)
}}
onCancel={() => setResetConfirm(false)}
/>
{contextMenu && (
<div
className="fixed z-50 bg-bg-card border border-border rounded-lg shadow-lg py-1 min-w-[140px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="w-full px-4 py-2 text-sm text-text-primary hover:bg-bg-hover text-left"
onClick={() => copyImageToClipboard(contextMenu.imageUrl)}
>
{t.editor.copyImage}
</button>
</div>
)}
{copyToast && (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-bg-card border border-border rounded-lg px-4 py-2 text-sm text-text-primary shadow-lg">
{copyToast}
</div>
)}
</div>
)
}
@@ -0,0 +1,34 @@
import { useStore } from '../stores'
import { Lightbulb } from 'lucide-react'
export function EmptyState() {
const { t, createBug } = useStore()
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="flex items-center justify-center mb-4">
<img src="/favicon.svg" alt="BugPack" className="w-12 h-12" />
</div>
<h2 className="text-xl font-semibold text-text-primary mb-2">
{t.empty.title}
</h2>
<p className="text-sm text-text-muted mb-6">{t.empty.subtitle}</p>
<button onClick={() => createBug()} className="px-6 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors">
{t.empty.createFirst}
</button>
<div className="mt-8 mx-auto max-w-sm bg-bg-card border border-border rounded-xl p-4">
<div className="flex items-start gap-2">
<Lightbulb className="w-4 h-4 text-yellow-400 mt-0.5 shrink-0" />
<p className="text-xs text-text-muted text-left leading-relaxed">
{t.empty.tip}
</p>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,330 @@
import { useState, useEffect, useRef } from 'react'
import { useStore } from '../stores'
import { api } from '../api'
import { X, Download, RefreshCw, ExternalLink, AlertCircle, ChevronDown, CheckSquare, Square } from 'lucide-react'
interface JiraBug {
id: string
key: string
title: string
priority: string
priorityId: string
status: string
statusCategory: string
reporter: string
created: string
hasAttachments: boolean
}
interface JiraProject {
id: string
key: string
name: string
}
export function JiraModal({ onClose }: { onClose: () => void }) {
const { locale, currentProjectId, fetchBugs, settings, saveSettings } = useStore()
const hasImported = useRef(false)
const zh = locale === 'zh'
const [projects, setProjects] = useState<JiraProject[]>([])
const [selectedProjectKey, setSelectedProjectKey] = useState(settings.jiraProjectKey || '')
const [bugs, setBugs] = useState<JiraBug[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [importing, setImporting] = useState<Set<string>>(new Set())
const [imported, setImported] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchImporting, setBatchImporting] = useState(false)
const [step, setStep] = useState<'projects' | 'bugs'>(settings.jiraProjectKey ? 'bugs' : 'projects')
// Load project list
const loadProjects = async () => {
setLoading(true)
setError('')
try {
const res = await api.jira.getProjects()
if (!res.ok) throw new Error(res.error || 'Failed to fetch projects')
setProjects(res.projects || [])
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
// Load bug list
const loadBugs = async (projectKey?: string) => {
const pk = projectKey || selectedProjectKey
if (!pk) { setStep('projects'); loadProjects(); return }
setLoading(true)
setError('')
try {
if (pk !== settings.jiraProjectKey) {
await saveSettings({ jiraProjectKey: pk })
}
const res = await api.jira.getBugs()
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
setBugs(res.bugs || [])
setStep('bugs')
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (settings.jiraProjectKey) {
loadBugs(settings.jiraProjectKey)
} else {
loadProjects()
}
}, [])
const selectProject = (key: string) => {
setSelectedProjectKey(key)
loadBugs(key)
}
// Import single bug
const handleImport = async (key: string) => {
setImporting(prev => new Set(prev).add(key))
try {
const res = await api.jira.importBug(key, currentProjectId)
if (!res.ok) throw new Error('Import failed')
setImported(prev => new Set(prev).add(key))
hasImported.current = true
} catch {
// ignore
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(key); return s })
}
}
const toggleSelect = (key: string) => {
setSelected(prev => {
const s = new Set(prev)
s.has(key) ? s.delete(key) : s.add(key)
return s
})
}
const toggleSelectAll = () => {
const importable = bugs.filter(b => !imported.has(b.key)).map(b => b.key)
const allSelected = importable.every(k => selected.has(k))
setSelected(allSelected ? new Set() : new Set(importable))
}
const handleBatchImport = async () => {
if (selected.size === 0) return
setBatchImporting(true)
const keys = [...selected].filter(k => !imported.has(k))
for (const key of keys) {
setImporting(prev => new Set(prev).add(key))
try {
const res = await api.jira.importBug(key, currentProjectId)
if (res.ok) setImported(prev => new Set(prev).add(key))
} catch {
// skip failed
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(key); return s })
}
}
setSelected(new Set())
hasImported.current = true
setBatchImporting(false)
fetchBugs()
onClose()
}
// Priority color
const priColor = (name: string) => {
const n = name.toLowerCase()
if (n.includes('high') || n.includes('critical') || n.includes('blocker')) return 'text-red-400'
if (n.includes('medium')) return 'text-yellow-400'
return 'text-text-muted'
}
const curProject = projects.find(p => p.key === selectedProjectKey)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<ExternalLink className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold text-text-primary">
{zh ? '从 Jira 导入' : 'Import from Jira'}
</h2>
{step === 'bugs' && (
<button
onClick={() => { setStep('projects'); loadProjects() }}
className="ml-2 flex items-center gap-1 px-2 py-0.5 text-xs text-blue-400 bg-blue-400/10 rounded hover:bg-blue-400/20 transition-colors"
>
{curProject ? `${curProject.name} (${curProject.key})` : selectedProjectKey}
<ChevronDown className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => step === 'bugs' ? loadBugs() : loadProjects()}
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{error && (
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div className="text-sm text-red-400">
<p>{error}</p>
<p className="text-xs text-red-400/60 mt-1">
{zh ? '请检查设置中的 Jira 配置' : 'Check Jira settings'}
</p>
</div>
</div>
)}
{loading && !error && (
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
{zh ? '加载中...' : 'Loading...'}
</div>
)}
{/* Project selection */}
{!loading && step === 'projects' && !error && (
<div className="p-6">
<p className="text-sm text-text-secondary mb-3">
{zh ? '选择 Jira 项目:' : 'Select a Jira project:'}
</p>
{projects.length === 0 ? (
<p className="text-sm text-text-muted text-center py-8">
{zh ? '没有找到项目(当前账号可能无权限)' : 'No projects found (no permission?)'}
</p>
) : (
<div className="space-y-2">
{projects.map(p => (
<button
key={p.id}
onClick={() => selectProject(p.key)}
className="w-full text-left px-4 py-3 bg-bg-input border border-border rounded-lg hover:border-blue-400 hover:bg-blue-400/5 transition-colors"
>
<span className="text-xs text-text-muted mr-2">{p.key}</span>
<span className="text-sm text-text-primary">{p.name}</span>
</button>
))}
</div>
)}
</div>
)}
{/* Bug list */}
{!loading && step === 'bugs' && !error && bugs.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
<p>{zh ? '该项目下没有指派给你的 Bug' : 'No bugs assigned to you'}</p>
</div>
)}
{!loading && step === 'bugs' && bugs.length > 0 && (
<div className="divide-y divide-border">
{bugs.map(bug => (
<div key={bug.key} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
<button
onClick={() => toggleSelect(bug.key)}
className={`shrink-0 ${imported.has(bug.key) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-blue-400'}`}
disabled={imported.has(bug.key)}
>
{selected.has(bug.key) ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs text-text-muted">{bug.key}</span>
<span className={`text-xs font-medium ${priColor(bug.priority)}`}>{bug.priority}</span>
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{bug.status}</span>
</div>
<p className="text-sm text-text-primary truncate">{bug.title}</p>
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
{bug.reporter && <span>{bug.reporter}</span>}
{bug.created && <span>{new Date(bug.created).toLocaleString()}</span>}
</div>
</div>
<button
onClick={() => handleImport(bug.key)}
disabled={importing.has(bug.key) || imported.has(bug.key)}
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
imported.has(bug.key)
? 'bg-green-500/20 text-green-400 cursor-default'
: importing.has(bug.key)
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-blue-400/20 text-blue-400 hover:bg-blue-400/30'
}`}
>
{imported.has(bug.key) ? (
zh ? '已导入' : 'Imported'
) : importing.has(bug.key) ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
<div className="flex items-center gap-3">
{step === 'bugs' && bugs.length > 0 && (
<button
onClick={toggleSelectAll}
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-blue-400 transition-colors"
>
{bugs.filter(b => !imported.has(b.key)).every(b => selected.has(b.key)) && bugs.some(b => !imported.has(b.key))
? <CheckSquare className="w-3.5 h-3.5 text-blue-400" />
: <Square className="w-3.5 h-3.5" />}
{zh ? '全选' : 'Select All'}
</button>
)}
<span className="text-xs text-text-muted">
{step === 'bugs'
? (zh
? `指派给我 ${bugs.length} 个 Bug${selected.size > 0 ? `,已选 ${selected.size}` : ''}`
: `${bugs.length} bugs assigned to me${selected.size > 0 ? `, ${selected.size} selected` : ''}`)
: (zh ? `${projects.length} 个项目` : `${projects.length} projects`)}
</span>
</div>
{step === 'bugs' && selected.size > 0 && (
<button
onClick={handleBatchImport}
disabled={batchImporting}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
batchImporting
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-blue-400 text-white hover:bg-blue-500'
}`}
>
{batchImporting ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
)}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,327 @@
import { useState, useEffect, useRef } from 'react'
import { useStore } from '../stores'
import { api } from '../api'
import { X, Download, RefreshCw, ExternalLink, AlertCircle, ChevronDown, CheckSquare, Square } from 'lucide-react'
interface LinearBug {
id: string
identifier: string
title: string
priority: number
priorityLabel: string
status: string
statusType: string
creator: string
created: string
hasAttachments: boolean
}
interface LinearTeam {
id: string
key: string
name: string
}
export function LinearModal({ onClose }: { onClose: () => void }) {
const { locale, currentProjectId, fetchBugs, settings, saveSettings } = useStore()
const zh = locale === 'zh'
const hasImported = useRef(false)
const [teams, setTeams] = useState<LinearTeam[]>([])
const [selectedTeamId, setSelectedTeamId] = useState(settings.linearTeamId || '')
const [bugs, setBugs] = useState<LinearBug[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [importing, setImporting] = useState<Set<string>>(new Set())
const [imported, setImported] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchImporting, setBatchImporting] = useState(false)
const [step, setStep] = useState<'teams' | 'bugs'>(settings.linearTeamId ? 'bugs' : 'teams')
const loadTeams = async () => {
setLoading(true)
setError('')
try {
const res = await api.linear.getTeams()
if (!res.ok) throw new Error(res.error || 'Failed to fetch teams')
setTeams(res.teams || [])
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
const loadBugs = async (teamId?: string) => {
const tid = teamId || selectedTeamId
if (!tid) { setStep('teams'); loadTeams(); return }
setLoading(true)
setError('')
try {
if (tid !== settings.linearTeamId) {
await saveSettings({ linearTeamId: tid })
}
const res = await api.linear.getBugs()
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
setBugs(res.bugs || [])
setStep('bugs')
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (settings.linearTeamId) {
loadBugs(settings.linearTeamId)
} else {
loadTeams()
}
}, [])
const selectTeam = (id: string) => {
setSelectedTeamId(id)
loadBugs(id)
}
const handleImport = async (id: string) => {
setImporting(prev => new Set(prev).add(id))
try {
const res = await api.linear.importBug(id, currentProjectId)
if (!res.ok) throw new Error('Import failed')
setImported(prev => new Set(prev).add(id))
hasImported.current = true
} catch {
// ignore
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
}
}
const toggleSelect = (id: string) => {
setSelected(prev => {
const s = new Set(prev)
s.has(id) ? s.delete(id) : s.add(id)
return s
})
}
const toggleSelectAll = () => {
const importable = bugs.filter(b => !imported.has(b.id)).map(b => b.id)
const allSelected = importable.every(id => selected.has(id))
setSelected(allSelected ? new Set() : new Set(importable))
}
const handleBatchImport = async () => {
if (selected.size === 0) return
setBatchImporting(true)
const ids = [...selected].filter(id => !imported.has(id))
for (const id of ids) {
setImporting(prev => new Set(prev).add(id))
try {
const res = await api.linear.importBug(id, currentProjectId)
if (res.ok) setImported(prev => new Set(prev).add(id))
} catch {
// skip failed
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
}
}
setSelected(new Set())
hasImported.current = true
setBatchImporting(false)
fetchBugs()
onClose()
}
// Priority color: 1=Urgent 2=High 3=Medium 4=Low
const priColor = (p: number) => {
if (p <= 1) return 'text-red-400'
if (p === 2) return 'text-orange-400'
if (p === 3) return 'text-yellow-400'
return 'text-text-muted'
}
const curTeam = teams.find(t => t.id === selectedTeamId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<ExternalLink className="w-5 h-5 text-violet-400" />
<h2 className="text-lg font-semibold text-text-primary">
{zh ? '从 Linear 导入' : 'Import from Linear'}
</h2>
{step === 'bugs' && (
<button
onClick={() => { setStep('teams'); loadTeams() }}
className="ml-2 flex items-center gap-1 px-2 py-0.5 text-xs text-violet-400 bg-violet-400/10 rounded hover:bg-violet-400/20 transition-colors"
>
{curTeam ? `${curTeam.name} (${curTeam.key})` : selectedTeamId}
<ChevronDown className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => step === 'bugs' ? loadBugs() : loadTeams()}
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{error && (
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div className="text-sm text-red-400">
<p>{error}</p>
<p className="text-xs text-red-400/60 mt-1">
{zh ? '请检查设置中的 Linear 配置' : 'Check Linear settings'}
</p>
</div>
</div>
)}
{loading && !error && (
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
{zh ? '加载中...' : 'Loading...'}
</div>
)}
{/* Team selection */}
{!loading && step === 'teams' && !error && (
<div className="p-6">
<p className="text-sm text-text-secondary mb-3">
{zh ? '选择 Linear 团队:' : 'Select a Linear team:'}
</p>
{teams.length === 0 ? (
<p className="text-sm text-text-muted text-center py-8">
{zh ? '没有找到团队' : 'No teams found'}
</p>
) : (
<div className="space-y-2">
{teams.map(t => (
<button
key={t.id}
onClick={() => selectTeam(t.id)}
className="w-full text-left px-4 py-3 bg-bg-input border border-border rounded-lg hover:border-violet-400 hover:bg-violet-400/5 transition-colors"
>
<span className="text-xs text-text-muted mr-2">{t.key}</span>
<span className="text-sm text-text-primary">{t.name}</span>
</button>
))}
</div>
)}
</div>
)}
{/* Issue list */}
{!loading && step === 'bugs' && !error && bugs.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
<p>{zh ? '该团队下没有指派给你的 Issue' : 'No issues assigned to you'}</p>
</div>
)}
{!loading && step === 'bugs' && bugs.length > 0 && (
<div className="divide-y divide-border">
{bugs.map(bug => (
<div key={bug.id} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
<button
onClick={() => toggleSelect(bug.id)}
className={`shrink-0 ${imported.has(bug.id) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-violet-400'}`}
disabled={imported.has(bug.id)}
>
{selected.has(bug.id) ? <CheckSquare className="w-4 h-4 text-violet-400" /> : <Square className="w-4 h-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs text-text-muted">{bug.identifier}</span>
<span className={`text-xs font-medium ${priColor(bug.priority)}`}>{bug.priorityLabel}</span>
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{bug.status}</span>
</div>
<p className="text-sm text-text-primary truncate">{bug.title}</p>
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
{bug.creator && <span>{bug.creator}</span>}
{bug.created && <span>{new Date(bug.created).toLocaleString()}</span>}
</div>
</div>
<button
onClick={() => handleImport(bug.id)}
disabled={importing.has(bug.id) || imported.has(bug.id)}
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
imported.has(bug.id)
? 'bg-green-500/20 text-green-400 cursor-default'
: importing.has(bug.id)
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-violet-400/20 text-violet-400 hover:bg-violet-400/30'
}`}
>
{imported.has(bug.id) ? (
zh ? '已导入' : 'Imported'
) : importing.has(bug.id) ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
<div className="flex items-center gap-3">
{step === 'bugs' && bugs.length > 0 && (
<button
onClick={toggleSelectAll}
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-violet-400 transition-colors"
>
{bugs.filter(b => !imported.has(b.id)).every(b => selected.has(b.id)) && bugs.some(b => !imported.has(b.id))
? <CheckSquare className="w-3.5 h-3.5 text-violet-400" />
: <Square className="w-3.5 h-3.5" />}
{zh ? '全选' : 'Select All'}
</button>
)}
<span className="text-xs text-text-muted">
{step === 'bugs'
? (zh
? `指派给我 ${bugs.length} 个 Issue${selected.size > 0 ? `,已选 ${selected.size}` : ''}`
: `${bugs.length} issues assigned to me${selected.size > 0 ? `, ${selected.size} selected` : ''}`)
: (zh ? `${teams.length} 个团队` : `${teams.length} teams`)}
</span>
</div>
{step === 'bugs' && selected.size > 0 && (
<button
onClick={handleBatchImport}
disabled={batchImporting}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
batchImporting
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-violet-400 text-white hover:bg-violet-500'
}`}
>
{batchImporting ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
)}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect } from 'react'
import { useStore } from '../stores'
import { ConfirmDialog } from './ConfirmDialog'
import { api } from '../api'
import {
Settings,
ChevronDown,
Plus,
Trash2,
Download,
Upload,
} from 'lucide-react'
export function Navbar() {
const {
t, locale, setSettingsOpen,
currentProject, currentProjectId, projects,
createProject, switchProject, deleteProject, fetchBugs,
} = useStore()
const zh = locale === 'zh'
// Project switching
const [projDropdown, setProjDropdown] = useState(false)
const [projCreating, setProjCreating] = useState(false)
const [projNewName, setProjNewName] = useState('')
const [projCreateLoading, setProjCreateLoading] = useState(false)
const [projImportLoading, setProjImportLoading] = useState(false)
const [projDeleteTarget, setProjDeleteTarget] = useState<{ id: string; name: string } | null>(null)
const projDropdownRef = useRef<HTMLDivElement>(null)
const projImportRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (projDropdownRef.current && !projDropdownRef.current.contains(e.target as Node)) {
setProjDropdown(false)
setProjCreating(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const handleProjCreate = async () => {
const name = projNewName.trim()
if (!name || projCreateLoading) return
setProjCreateLoading(true)
try {
await createProject(name)
setProjNewName('')
setProjCreating(false)
setProjDropdown(false)
} finally {
setProjCreateLoading(false)
}
}
const handleProjImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || projImportLoading) return
setProjImportLoading(true)
try {
const res = await api.importProject(currentProjectId, file)
if (res.ok) {
await fetchBugs()
setProjDropdown(false)
} else {
console.error('Import failed:',res.error)
}
} catch (err) {
console.error('Import failed:',err)
} finally {
setProjImportLoading(false)
}
e.target.value = ''
}
return (
<header className="h-12 bg-bg-primary border-b border-border flex items-center justify-between px-4 shrink-0">
{/* Left Logo */}
<div className="flex items-center gap-2">
<img src="/favicon.svg" alt="BugPack" className="w-7 h-7" />
<span className="text-base font-bold tracking-tight text-text-primary">{t.app.name}</span>
</div>
{/* Right side */}
<div className="flex items-center gap-2">
{/* Project switcher */}
<div className="relative" ref={projDropdownRef}>
<button
onClick={() => setProjDropdown(!projDropdown)}
className="flex items-center gap-2 px-4 py-1.5 rounded-full border border-border text-sm hover:bg-bg-hover transition-colors"
>
<span className="text-text-muted">{t.nav.project}:</span>
<span className="text-text-primary font-medium">{currentProject}</span>
<ChevronDown className={`w-3 h-3 text-text-muted transition-transform ${projDropdown ? 'rotate-180' : ''}`} />
</button>
{projDropdown && (
<div className="absolute top-full right-0 mt-1 w-64 bg-bg-card border border-border rounded-lg shadow-xl z-50 overflow-hidden">
{/* Project list */}
<div className="max-h-48 overflow-y-auto">
{projects.map((p) => (
<div
key={p.id}
className={`flex items-center justify-between px-3 py-2 text-sm cursor-pointer transition-colors group ${
p.id === currentProjectId ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-bg-hover'
}`}
onClick={() => { switchProject(p.id); setProjDropdown(false) }}
>
<span className="truncate">{p.name}</span>
<button
onClick={(e) => { e.stopPropagation(); setProjDeleteTarget({ id: p.id, name: p.name }) }}
className="p-1 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
{/* New project input (shown when expanded) */}
{projCreating && (
<div className="p-2">
<div className="flex gap-1.5">
<input
autoFocus
value={projNewName}
onChange={(e) => setProjNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleProjCreate(); if (e.key === 'Escape') setProjCreating(false) }}
placeholder={zh ? '项目名称' : 'Project name'}
className="flex-1 px-2 py-1.5 bg-bg-input border border-border rounded text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
/>
<button
onClick={handleProjCreate}
disabled={projCreateLoading}
className={`px-3 py-1.5 text-white text-xs rounded transition-colors ${projCreateLoading ? 'bg-accent/50 cursor-wait' : 'bg-accent hover:bg-accent-hover'}`}
>
{projCreateLoading ? '...' : (zh ? '创建' : 'OK')}
</button>
</div>
</div>
)}
{/* New / Export / Import in one row */}
<div className="px-2 py-1.5 flex gap-1">
{!projCreating && (
<button
onClick={() => setProjCreating(true)}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-accent bg-accent/10 hover:bg-accent/20 rounded transition-colors"
>
<Plus className="w-3 h-3" />
{zh ? '新建' : 'New'}
</button>
)}
<a
href={api.exportProject(currentProjectId)}
download
onClick={() => setProjDropdown(false)}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-text-secondary hover:bg-bg-hover rounded transition-colors"
>
<Download className="w-3 h-3" />
{zh ? '导出' : 'Export'}
</a>
<button
onClick={() => { if (!projImportLoading) projImportRef.current?.click() }}
disabled={projImportLoading}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs rounded transition-colors ${
projImportLoading ? 'text-text-muted cursor-wait' : 'text-text-secondary hover:bg-bg-hover'
}`}
>
<Upload className={`w-3 h-3 ${projImportLoading ? 'animate-spin' : ''}`} />
{projImportLoading ? '...' : (zh ? '导入' : 'Import')}
</button>
</div>
</div>
)}
<input ref={projImportRef} type="file" accept=".zip" className="hidden" onChange={handleProjImport} />
</div>
{/* Settings */}
<button
onClick={() => setSettingsOpen(true)}
className="p-2 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
title={t.nav.settings}
>
<Settings className="w-4 h-4" />
</button>
</div>
{/* Delete project confirmation */}
<ConfirmDialog
open={!!projDeleteTarget}
title={zh ? '删除项目' : 'Delete Project'}
message={zh
? `确定删除项目「${projDeleteTarget?.name}」?关联的 Bug 也会被删除。`
: `Delete project "${projDeleteTarget?.name}"? Related bugs will also be deleted.`}
confirmText={zh ? '确认删除' : 'Delete'}
cancelText={zh ? '取消' : 'Cancel'}
onConfirm={() => { if (projDeleteTarget) deleteProject(projDeleteTarget.id); setProjDeleteTarget(null) }}
onCancel={() => setProjDeleteTarget(null)}
/>
</header>
)
}
@@ -0,0 +1,160 @@
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'
import { marked } from 'marked'
import morphdom from 'morphdom'
import { useStore, type Bug } from '../stores'
import { generateInstruction } from '../utils/generateInstruction'
import { ArrowLeft, Copy, Download, Check } from 'lucide-react'
// Configure marked: add section id to h2
const renderer = new marked.Renderer()
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
let id = ''
if (depth === 2) {
if (text.includes('Screenshot') || text.includes('截图')) id = 'section-screenshots'
else if (text.includes('Environment') || text.includes('环境')) id = 'section-environment'
else if (text.includes('File') || text.includes('文件')) id = 'section-files'
else if (text.includes('Priority') || text.includes('优先') || text.includes('Instruction') || text.includes('指令')) id = 'section-ai'
}
return `<h${depth}${id ? ` id="${id}"` : ''}>${text}</h${depth}>`
}
marked.use({ renderer, gfm: true, breaks: false, async: false })
export function PreviewArea({ bug }: { bug: Bug }) {
const { t, locale, setViewMode, compareMode, compareLeft, compareRight, currentProject } = useStore()
const [copied, setCopied] = useState(false)
// Generate Markdown source
const markdown = useMemo(() => {
return generateInstruction(bug, locale, {
enabled: compareMode,
leftIndex: compareLeft,
rightIndex: compareRight,
}, currentProject)
}, [bug, locale, compareMode, compareLeft, compareRight, currentProject])
// Convert to HTML
const html = useMemo(() => marked.parse(markdown) as string, [markdown])
// morphdom: only patch changed DOM nodes, keep unchanged elements in place
const mdRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!mdRef.current) return
// Create temp div for new content
const tmp = document.createElement('div')
tmp.innerHTML = html
// morphdom diffs old/new DOM, updates only changes
morphdom(mdRef.current, tmp, { childrenOnly: true })
}, [html])
// Copy to clipboard
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(markdown)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [markdown])
// Export .md file
const handleExport = useCallback(() => {
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `bug-${String(bug.number).padStart(3, '0')}.md`
a.click()
URL.revokeObjectURL(url)
}, [markdown, bug.number])
// Active navigation item
const [activeNav, setActiveNav] = useState(0)
return (
<div className="flex h-full">
{/* Left sidebar table of contents */}
<div className="w-48 bg-bg-sidebar border-r border-border p-4 shrink-0">
<button
onClick={() => setViewMode('edit')}
className="flex items-center gap-1.5 text-sm text-accent hover:text-accent-hover mb-6 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t.preview.backToEdit}
</button>
<p className="text-[10px] text-text-muted uppercase tracking-wider mb-3">{t.preview.contents}</p>
<nav className="space-y-2">
{[
bug.screenshots.length > 0 && { label: t.preview.screenshots, id: 'section-screenshots' },
(bug.pagePath || bug.device || bug.browser) && { label: t.preview.environment, id: 'section-environment' },
bug.relatedFiles.length > 0 && { label: t.preview.relatedFilesSection, id: 'section-files' },
{ label: t.preview.aiInstructions, id: 'section-ai' },
].filter((x): x is { label: string; id: string } => !!x).map(({ label, id }, i) => (
<button
key={i}
onClick={() => {
setActiveNav(i)
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}}
className={`block text-sm text-left w-full transition-colors ${
activeNav === i ? 'text-accent' : 'text-text-muted hover:text-text-secondary'
}`}
>
{label}
</button>
))}
</nav>
</div>
{/* Center Markdown preview */}
<div className="flex-1 overflow-y-auto">
{/* Action bar */}
<div className="sticky top-0 bg-bg-primary/90 backdrop-blur-sm border-b border-border px-6 py-3 flex items-center justify-between z-10">
<h2 className="text-sm text-text-secondary">
Bug #{String(bug.number).padStart(3, '0')}
</h2>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-text-secondary bg-bg-input border border-border rounded-lg hover:bg-bg-hover transition-colors"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? (locale === 'zh' ? '已复制' : 'Copied') : t.preview.copy}
</button>
<button
onClick={handleExport}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-text-secondary bg-bg-input border border-border rounded-lg hover:bg-bg-hover transition-colors"
>
<Download className="w-3.5 h-3.5" />
{t.preview.export}
</button>
</div>
</div>
{/* Markdown render */}
<div className="max-w-5xl mx-auto p-8 prose prose-invert prose-sm max-w-none
prose-headings:text-text-primary prose-p:text-text-secondary prose-li:text-text-secondary
prose-a:text-accent prose-strong:text-text-primary prose-code:text-accent
prose-img:rounded-lg prose-img:border prose-img:border-border
prose-h1:text-2xl prose-h1:font-bold prose-h1:mb-4
prose-h2:text-lg prose-h2:font-semibold prose-h2:text-accent prose-h2:mt-8 prose-h2:mb-3
prose-h3:text-base prose-h3:font-medium prose-h3:mt-4 prose-h3:mb-2
prose-ul:my-2 prose-ol:my-2
">
<div ref={mdRef} />
</div>
{/* Raw Markdown source */}
<div className="max-w-5xl mx-auto px-8 pb-4">
<details className="group">
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-secondary mb-2">
{locale === 'zh' ? '查看原始 Markdown' : 'View Raw Markdown'}
</summary>
<pre className="text-xs text-text-muted bg-bg-card border border-border rounded-lg p-4 overflow-x-auto whitespace-pre-wrap">
{markdown}
</pre>
</details>
</div>
</div>
</div>
)
}
@@ -0,0 +1,165 @@
import { useState, useCallback } from 'react'
import { useStore, type Bug, type BugStatus, type Priority } from '../stores'
import { generateInstruction } from '../utils/generateInstruction'
import {
ChevronDown,
ChevronUp,
Zap,
Copy,
FileText,
} from 'lucide-react'
// Collapsible Section
function Section({
title,
defaultOpen = true,
children,
}: {
title: string
defaultOpen?: boolean
children: React.ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="border-b border-border">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<span className="text-sm font-medium text-text-primary">{title}</span>
{open ? (
<ChevronUp className="w-4 h-4 text-text-muted" />
) : (
<ChevronDown className="w-4 h-4 text-text-muted" />
)}
</button>
{open && <div className="px-4 pb-4 space-y-3">{children}</div>}
</div>
)
}
const statusOptions: BugStatus[] = ['pending', 'fixed', 'closed']
const priorityOptions: { key: Priority; color: string }[] = [
{ key: 'high', color: 'bg-red-500/20 text-red-400 border-red-500/30' },
{ key: 'medium', color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
{ key: 'low', color: 'bg-green-500/20 text-green-400 border-green-500/30' },
]
export function PropertyPanel({ bug, width }: { bug: Bug; width?: number }) {
const { t, locale, setViewMode, updateBug, compareMode, compareLeft, compareRight, currentProject, bugs, selectBug, clearSelection } = useStore()
// Debounced update
const handleFieldBlur = useCallback((field: string, value: string) => {
updateBug(bug.id, { [field]: value })
}, [bug.id, updateBug])
return (
<aside style={{ width: width ?? 320 }} className="bg-bg-sidebar border-l border-border flex flex-col shrink-0 overflow-y-auto">
{/* Bug info */}
<Section title={t.panel.bugInfo}>
<div>
<label className="text-xs text-text-muted block mb-1">{t.panel.title}</label>
<input
type="text"
defaultValue={bug.title}
key={`title-${bug.id}`}
placeholder={t.panel.titlePlaceholder}
onBlur={(e) => handleFieldBlur('title', e.target.value)}
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">{t.panel.description}</label>
<textarea
defaultValue={bug.description}
key={`desc-${bug.id}`}
placeholder={t.panel.descriptionPlaceholder}
rows={4}
onBlur={(e) => handleFieldBlur('description', e.target.value)}
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent resize-y min-h-[96px]"
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1.5">{t.panel.priority}</label>
<div className="flex gap-2">
{priorityOptions.map(({ key, color }) => (
<button
key={key}
onClick={() => updateBug(bug.id, { priority: key })}
className={`flex-1 py-1.5 text-xs rounded-lg border transition-colors ${
bug.priority === key ? color : 'border-border text-text-muted hover:border-border'
}`}
>
{t.priority[key]}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">{t.panel.statusLabel}</label>
<select
defaultValue={bug.status}
key={`status-${bug.id}`}
onChange={(e) => {
const newStatus = e.target.value
updateBug(bug.id, { status: newStatus })
// 状态变化时,自动跳转到下一个待处理的 bug
const nextPending = bugs.find(b => b.id !== bug.id && (b.status === 'pending' || b.status === 'annotating'))
if (nextPending) {
selectBug(nextPending.id)
} else {
// 没有待处理的 bug,清空选中状态
clearSelection()
}
}}
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent"
>
{statusOptions.map((s) => (
<option key={s} value={s}>{t.status[s]}</option>
))}
</select>
</div>
</Section>
{/* Bottom action buttons */}
<div className="mt-auto p-4 space-y-2">
<button
onClick={() => setViewMode('preview')}
className="w-full py-3 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Zap className="w-4 h-4" />
{t.panel.generateBtn}
<span className="text-xs opacity-60 ml-1">{t.panel.generateShortcut}</span>
</button>
<div className="flex gap-2">
<button
onClick={() => {
const md = generateInstruction(bug, locale, { enabled: compareMode, leftIndex: compareLeft, rightIndex: compareRight }, currentProject)
navigator.clipboard.writeText(md)
}}
className="flex-1 py-2 bg-bg-input border border-border text-text-secondary text-sm rounded-lg hover:bg-bg-hover transition-colors flex items-center justify-center gap-1.5"
>
<Copy className="w-3.5 h-3.5" />
{t.panel.copyBtn}
</button>
<button
onClick={() => {
const md = generateInstruction(bug, locale, { enabled: compareMode, leftIndex: compareLeft, rightIndex: compareRight }, currentProject)
const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `bug-${String(bug.number).padStart(3, '0')}.md`
a.click()
URL.revokeObjectURL(url)
}}
className="flex-1 py-2 bg-bg-input border border-border text-text-secondary text-sm rounded-lg hover:bg-bg-hover transition-colors flex items-center justify-center gap-1.5"
>
<FileText className="w-3.5 h-3.5" />
{t.panel.exportBtn}
</button>
</div>
</div>
</aside>
)
}
@@ -0,0 +1,509 @@
import { useState, useEffect } from 'react'
import { useStore } from '../stores'
import { api } from '../api'
import {
X,
FolderOpen,
Settings,
Palette,
Globe,
Search,
Keyboard,
Info,
} from 'lucide-react'
type SettingsTab = 'project' | 'appearance' | 'integrations' | 'shortcuts' | 'about'
export function SettingsModal() {
const { t, locale, setLocale, theme, setTheme, setSettingsOpen, settings, saveSettings, fetchSettings, currentProjectId, currentProject, projects } = useStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('project')
// Restore connection status from settings
const [zentaoStatus, setZentaoStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.zentaoConnected === 'true' ? 'ok' : 'idle')
const [zentaoError, setZentaoError] = useState('')
const [jiraStatus, setJiraStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.jiraConnected === 'true' ? 'ok' : 'idle')
const [jiraError, setJiraError] = useState('')
const [linearStatus, setLinearStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.linearConnected === 'true' ? 'ok' : 'idle')
const [linearError, setLinearError] = useState('')
const [tapdStatus, setTapdStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.tapdConnected === 'true' ? 'ok' : 'idle')
const [tapdError, setTapdError] = useState('')
// Search
const [searchQuery, setSearchQuery] = useState('')
// Local form state
const [form, setForm] = useState({
projectName: currentProject || '',
rootDir: '',
zentaoUrl: '',
zentaoHttpUser: '',
zentaoHttpPass: '',
zentaoAccount: '',
zentaoPassword: '',
zentaoProductId: '',
jiraUrl: '',
jiraEmail: '',
jiraToken: '',
jiraProjectKey: '',
linearToken: '',
linearTeamId: '',
tapdApiUser: '',
tapdApiPassword: '',
tapdWorkspaceId: '',
})
useEffect(() => { fetchSettings() }, [fetchSettings])
useEffect(() => {
setForm((prev) => ({
...prev,
projectName: currentProject || '',
rootDir: settings[`rootDir_${currentProjectId}`] || '',
zentaoUrl: settings.zentaoUrl || '',
zentaoHttpUser: settings.zentaoHttpUser || '',
zentaoHttpPass: settings.zentaoHttpPass || '',
zentaoAccount: settings.zentaoAccount || '',
zentaoPassword: settings.zentaoPassword || '',
zentaoProductId: settings.zentaoProductId || '',
jiraUrl: settings.jiraUrl || '',
jiraEmail: settings.jiraEmail || '',
jiraToken: settings.jiraToken || '',
jiraProjectKey: settings.jiraProjectKey || '',
linearToken: settings.linearToken || '',
linearTeamId: settings.linearTeamId || '',
tapdApiUser: settings.tapdApiUser || '',
tapdApiPassword: settings.tapdApiPassword || '',
tapdWorkspaceId: settings.tapdWorkspaceId || '',
}))
}, [settings, currentProject, currentProjectId])
const updateField = (key: string, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const pickDirectory = async (field: string) => {
try {
const { path } = await api.pickDirectory()
if (path) updateField(field, path)
} catch { /* user cancelled */ }
}
const handleSave = async () => {
// Rename project
if (form.projectName && form.projectName !== currentProject) {
await api.renameProject(currentProjectId, form.projectName)
// Sync local projects list and currentProject
const updatedProjects = projects.map(p =>
p.id === currentProjectId ? { ...p, name: form.projectName } : p
)
useStore.setState({ projects: updatedProjects, currentProject: form.projectName })
}
// rootDir stored per project
const { projectName, rootDir, ...rest } = form
await saveSettings({
...rest,
[`rootDir_${currentProjectId}`]: rootDir,
})
setSettingsOpen(false)
}
const inputCls = "w-full px-3 py-2 bg-bg-input border border-border rounded text-sm text-text-primary focus:outline-none focus:border-accent"
const selectCls = inputCls
const labelCls = "text-sm font-medium text-text-secondary"
// Navigation items
const navItems: { key: SettingsTab; label: string; icon: typeof FolderOpen }[] = [
{ key: 'project', label: t.settings.projectConfig, icon: FolderOpen },
{ key: 'appearance', label: t.settings.appearance, icon: Palette },
{ key: 'integrations', label: t.settings.integrations, icon: Globe },
{ key: 'shortcuts', label: t.settings.shortcuts, icon: Keyboard },
{ key: 'about', label: t.settings.about, icon: Info },
]
const filteredNav = searchQuery
? navItems.filter(n => n.label.toLowerCase().includes(searchQuery.toLowerCase()))
: navItems
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-[900px] h-[640px] bg-bg-card border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-accent" />
<h2 className="text-lg font-bold text-text-primary">{t.settings.title}</h2>
</div>
<button
onClick={() => setSettingsOpen(false)}
className="text-text-secondary hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body: two columns */}
<div className="flex flex-1 overflow-hidden">
{/* Left navigation */}
<div className="w-56 border-r border-border flex flex-col bg-bg-input/50 shrink-0">
{/* Search */}
<div className="p-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-secondary" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={locale === 'zh' ? '搜索设置...' : 'Search settings...'}
className="w-full bg-bg-input border border-border rounded px-8 py-1.5 text-xs text-text-primary focus:border-accent outline-none transition-colors placeholder:text-text-secondary/50"
/>
</div>
</div>
{/* Navigation list */}
<nav className="flex-1 px-2 space-y-0.5 overflow-y-auto">
{filteredNav.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm whitespace-nowrap transition-colors ${
activeTab === key
? 'bg-accent/15 text-accent font-medium'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`}
>
<Icon className="w-[18px] h-[18px]" />
<span>{label}</span>
</button>
))}
</nav>
</div>
{/* Right content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-xl">
{/* Project config */}
{activeTab === 'project' && (
<div>
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.projectConfig}</h3>
<div className="space-y-5">
<div className="flex flex-col gap-1.5">
<label className={labelCls}>{t.settings.projectName}</label>
<input type="text" value={form.projectName} onChange={(e) => updateField('projectName', e.target.value)} className={inputCls} />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls}>{t.settings.rootDir}</label>
<div className="flex gap-2">
<input type="text" value={form.rootDir} onChange={(e) => updateField('rootDir', e.target.value)} placeholder="D:\projects\my-app" className={`flex-1 px-3 py-2 font-mono bg-bg-input border border-border rounded text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-accent`} />
<button onClick={() => pickDirectory('rootDir')} className="px-3 bg-bg-hover hover:bg-accent/20 rounded border border-border transition-colors flex items-center">
<FolderOpen className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
</div>
</div>
)}
{/* Appearance */}
{activeTab === 'appearance' && (
<div>
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.appearance}</h3>
<div className="space-y-5">
{/* Theme toggle */}
<div className="flex items-center justify-between">
<span className={labelCls}>{locale === 'zh' ? '编辑器主题' : 'Editor Theme'}</span>
<div className="flex gap-1 p-1 bg-bg-input border border-border rounded-md">
<button
onClick={() => setTheme('light')}
className={`px-4 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'light' ? 'bg-accent text-white shadow-sm' : 'text-text-secondary hover:text-text-primary'}`}
>
{locale === 'zh' ? '亮色' : 'Light'}
</button>
<button
onClick={() => setTheme('dark')}
className={`px-4 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-accent text-white shadow-sm' : 'text-text-secondary hover:text-text-primary'}`}
>
{locale === 'zh' ? '暗色' : 'Dark'}
</button>
</div>
</div>
{/* Language */}
<div className="flex flex-col gap-1.5">
<label className={labelCls}>{locale === 'zh' ? '语言' : 'Language'}</label>
<select value={locale} onChange={(e) => setLocale(e.target.value as 'zh' | 'en')} className={selectCls}>
<option value="zh"></option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
)}
{/* Shortcuts */}
{activeTab === 'shortcuts' && (
<div>
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.shortcuts}</h3>
<div className="space-y-3">
{[
{ key: 'Ctrl+V', desc: locale === 'zh' ? '粘贴截图' : 'Paste Screenshot' },
{ key: 'Ctrl+Enter', desc: locale === 'zh' ? '生成 AI 指令' : 'Generate AI Instructions' },
{ key: 'Ctrl+Z', desc: locale === 'zh' ? '撤销标注' : 'Undo Annotation' },
{ key: 'Ctrl+Shift+Z', desc: locale === 'zh' ? '重做标注' : 'Redo Annotation' },
{ key: 'V', desc: locale === 'zh' ? '选择工具' : 'Select Tool' },
{ key: 'R', desc: locale === 'zh' ? '矩形框' : 'Rectangle' },
{ key: 'A', desc: locale === 'zh' ? '箭头' : 'Arrow' },
{ key: 'T', desc: locale === 'zh' ? '文字' : 'Text' },
{ key: 'N', desc: locale === 'zh' ? '序号' : 'Number' },
{ key: 'H', desc: locale === 'zh' ? '高亮' : 'Highlight' },
{ key: 'P', desc: locale === 'zh' ? '画笔' : 'Pen' },
{ key: 'M', desc: locale === 'zh' ? '马赛克' : 'Mosaic' },
{ key: 'Delete', desc: locale === 'zh' ? '删除选中' : 'Delete Selected' },
].map(({ key, desc }) => (
<div key={key} className="flex items-center justify-between py-1.5">
<span className="text-sm text-text-secondary">{desc}</span>
<kbd className="px-2 py-1 bg-bg-input border border-border rounded text-xs text-text-muted font-mono">{key}</kbd>
</div>
))}
</div>
</div>
)}
{/* About */}
{activeTab === 'about' && (
<div>
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.about}</h3>
<div className="space-y-4">
<div className="flex items-center justify-between py-2">
<span className="text-sm text-text-secondary">{locale === 'zh' ? '版本' : 'Version'}</span>
<span className="text-sm text-text-primary font-mono">{t.app.version}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-text-secondary">{locale === 'zh' ? '应用名称' : 'App Name'}</span>
<span className="text-sm text-text-primary">{t.app.name}</span>
</div>
</div>
</div>
)}
{/* External platform integrations */}
{activeTab === 'integrations' && (
<div>
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.integrations}</h3>
<div className="space-y-4">
{/* Zentao */}
<div className="border border-border rounded-lg p-4 space-y-3">
<span className="relative inline-block text-sm font-medium text-text-primary">
{locale === 'zh' ? '禅道' : 'Zentao'}
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
zentaoStatus === 'ok' || settings.zentaoConnected === 'true' ? 'bg-green-500' :
zentaoStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
zentaoStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
}`} />
</span>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-text-secondary">{locale === 'zh' ? '禅道地址' : 'Zentao URL'}</label>
<input type="text" value={form.zentaoUrl} onChange={(e) => updateField('zentaoUrl', e.target.value)} placeholder="http://zentao.company.com" className={inputCls} />
</div>
<div>
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? '公司网关认证(HTTP Basic Auth' : 'Gateway Auth (HTTP Basic Auth)'}</label>
<div className="grid grid-cols-2 gap-2">
<input type="text" value={form.zentaoHttpUser} onChange={(e) => updateField('zentaoHttpUser', e.target.value)} placeholder={locale === 'zh' ? '网关账号' : 'Gateway user'} className={inputCls} />
<input type="password" value={form.zentaoHttpPass} onChange={(e) => updateField('zentaoHttpPass', e.target.value)} placeholder={locale === 'zh' ? '网关密码' : 'Gateway password'} className={inputCls} />
</div>
</div>
<div>
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? '禅道系统账号' : 'Zentao Account'}</label>
<div className="grid grid-cols-2 gap-2">
<input type="text" value={form.zentaoAccount} onChange={(e) => updateField('zentaoAccount', e.target.value)} placeholder={locale === 'zh' ? '禅道用户名' : 'Username'} className={inputCls} />
<input type="password" value={form.zentaoPassword} onChange={(e) => updateField('zentaoPassword', e.target.value)} placeholder={locale === 'zh' ? '禅道密码' : 'Password'} className={inputCls} />
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-text-secondary">{locale === 'zh' ? '产品 ID' : 'Product ID'}</label>
<input type="text" value={form.zentaoProductId} onChange={(e) => updateField('zentaoProductId', e.target.value)} placeholder={locale === 'zh' ? '禅道产品编号' : 'Zentao product number'} className={inputCls} />
</div>
<button
onClick={async () => {
if (!form.zentaoUrl || !form.zentaoAccount || !form.zentaoPassword) return
setZentaoStatus('testing'); setZentaoError('')
try {
const res = await api.zentao.test({ url: form.zentaoUrl, httpUser: form.zentaoHttpUser, httpPass: form.zentaoHttpPass, account: form.zentaoAccount, password: form.zentaoPassword })
setZentaoStatus(res.ok ? 'ok' : 'fail')
if (res.ok) saveSettings({ zentaoConnected: 'true' })
else { saveSettings({ zentaoConnected: '' }); if (res.error) setZentaoError(res.error) }
} catch (e: any) { setZentaoStatus('fail'); setZentaoError(e.message || 'Connection failed') }
}}
className={`w-full px-3 py-2 text-sm rounded transition-colors ${zentaoStatus === 'ok' ? 'bg-green-500/20 text-green-400' : zentaoStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-accent/20 text-accent hover:bg-accent/30'}`}
>
{zentaoStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
zentaoStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
zentaoStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
(locale === 'zh' ? '测试连接' : 'Test Connection')}
</button>
{zentaoError && <p className="text-xs text-red-400 break-all">{zentaoError}</p>}
</div>
{/* Jira */}
<div className="border border-border rounded-lg p-4 space-y-3">
<span className="relative inline-block text-sm font-medium text-text-primary">
Jira
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
jiraStatus === 'ok' || settings.jiraConnected === 'true' ? 'bg-green-500' :
jiraStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
jiraStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
}`} />
</span>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-text-secondary">{locale === 'zh' ? 'Jira 地址' : 'Jira URL'}</label>
<input type="text" value={form.jiraUrl} onChange={(e) => updateField('jiraUrl', e.target.value)} placeholder="https://your-team.atlassian.net" className={inputCls} />
</div>
<div>
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? 'Jira 账号' : 'Jira Account'}</label>
<div className="grid grid-cols-2 gap-2">
<input type="text" value={form.jiraEmail} onChange={(e) => updateField('jiraEmail', e.target.value)} placeholder={locale === 'zh' ? '邮箱地址' : 'Email'} className={inputCls} />
<input type="password" value={form.jiraToken} onChange={(e) => updateField('jiraToken', e.target.value)} placeholder="API Token" className={inputCls} />
</div>
<p className="text-[10px] text-text-secondary mt-1">
{locale === 'zh' ? '前往 id.atlassian.com → 安全 → 创建 API 令牌' : 'Go to id.atlassian.com → Security → Create API token'}
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-text-secondary">{locale === 'zh' ? '项目 Key' : 'Project Key'}</label>
<input type="text" value={form.jiraProjectKey} onChange={(e) => updateField('jiraProjectKey', e.target.value)} placeholder={locale === 'zh' ? '例: PROJ' : 'e.g. PROJ'} className={inputCls} />
</div>
<button
onClick={async () => {
if (!form.jiraUrl || !form.jiraEmail || !form.jiraToken) return
setJiraStatus('testing'); setJiraError('')
try {
const res = await api.jira.test({ url: form.jiraUrl, email: form.jiraEmail, token: form.jiraToken })
setJiraStatus(res.ok ? 'ok' : 'fail')
if (res.ok) saveSettings({ jiraConnected: 'true' })
else { saveSettings({ jiraConnected: '' }); if (res.error) setJiraError(res.error) }
} catch (e: any) { setJiraStatus('fail'); setJiraError(e.message || 'Connection failed') }
}}
className={`w-full px-3 py-2 text-sm rounded transition-colors ${jiraStatus === 'ok' ? 'bg-green-500/20 text-green-400' : jiraStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-blue-400/20 text-blue-400 hover:bg-blue-400/30'}`}
>
{jiraStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
jiraStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
jiraStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
(locale === 'zh' ? '测试连接' : 'Test Connection')}
</button>
{jiraError && <p className="text-xs text-red-400 break-all">{jiraError}</p>}
</div>
{/* Linear */}
<div className="border border-border rounded-lg p-4 space-y-3">
<span className="relative inline-block text-sm font-medium text-text-primary">
Linear
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
linearStatus === 'ok' || settings.linearConnected === 'true' ? 'bg-green-500' :
linearStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
linearStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
}`} />
</span>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-text-secondary">API Key</label>
<input type="password" value={form.linearToken} onChange={(e) => updateField('linearToken', e.target.value)} placeholder="lin_api_..." className={inputCls} />
<p className="text-[10px] text-text-secondary">
{locale === 'zh' ? '前往 Linear Settings → API → Personal API keys 生成' : 'Go to Linear Settings → API → Personal API keys'}
</p>
</div>
<button
onClick={async () => {
if (!form.linearToken) return
setLinearStatus('testing'); setLinearError('')
try {
const res = await api.linear.test({ token: form.linearToken })
setLinearStatus(res.ok ? 'ok' : 'fail')
if (res.ok) saveSettings({ linearConnected: 'true' })
else { saveSettings({ linearConnected: '' }); if (res.error) setLinearError(res.error) }
} catch (e: any) { setLinearStatus('fail'); setLinearError(e.message || 'Connection failed') }
}}
className={`w-full px-3 py-2 text-sm rounded transition-colors ${linearStatus === 'ok' ? 'bg-green-500/20 text-green-400' : linearStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-violet-400/20 text-violet-400 hover:bg-violet-400/30'}`}
>
{linearStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
linearStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
linearStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
(locale === 'zh' ? '测试连接' : 'Test Connection')}
</button>
{linearError && <p className="text-xs text-red-400 break-all">{linearError}</p>}
</div>
{/* TAPD */}
<div className="border border-border rounded-lg p-4 space-y-3">
<span className="relative inline-block text-sm font-medium text-text-primary">
TAPD
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
tapdStatus === 'ok' || settings.tapdConnected === 'true' ? 'bg-green-500' :
tapdStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
tapdStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
}`} />
</span>
<div>
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? 'API 账号' : 'API Account'}</label>
<div className="grid grid-cols-2 gap-2">
<input type="text" value={form.tapdApiUser} onChange={(e) => updateField('tapdApiUser', e.target.value)} placeholder={locale === 'zh' ? 'API 账号' : 'API User'} className={inputCls} />
<input type="password" value={form.tapdApiPassword} onChange={(e) => updateField('tapdApiPassword', e.target.value)} placeholder={locale === 'zh' ? 'API 密码' : 'API Password'} className={inputCls} />
</div>
<p className="text-[10px] text-text-secondary mt-1">
{locale === 'zh' ? '在 TAPD 项目设置 → 应用设置 → API 中获取' : 'Get from TAPD Project Settings → App Settings → API'}
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-text-secondary">{locale === 'zh' ? '项目 IDworkspace_id' : 'Project ID (workspace_id)'}</label>
<input type="text" value={form.tapdWorkspaceId} onChange={(e) => updateField('tapdWorkspaceId', e.target.value)} placeholder={locale === 'zh' ? '从 TAPD 项目 URL 中获取' : 'From TAPD project URL'} className={inputCls} />
</div>
<button
onClick={async () => {
if (!form.tapdApiUser || !form.tapdApiPassword) return
setTapdStatus('testing'); setTapdError('')
try {
const res = await api.tapd.test({ apiUser: form.tapdApiUser, apiPassword: form.tapdApiPassword, workspaceId: form.tapdWorkspaceId })
setTapdStatus(res.ok ? 'ok' : 'fail')
if (res.ok) saveSettings({ tapdConnected: 'true' })
else { saveSettings({ tapdConnected: '' }); if (res.error) setTapdError(res.error) }
} catch (e: any) { setTapdStatus('fail'); setTapdError(e.message || 'Connection failed') }
}}
className={`w-full px-3 py-2 text-sm rounded transition-colors ${tapdStatus === 'ok' ? 'bg-green-500/20 text-green-400' : tapdStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-cyan-400/20 text-cyan-400 hover:bg-cyan-400/30'}`}
>
{tapdStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
tapdStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
tapdStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
(locale === 'zh' ? '测试连接' : 'Test Connection')}
</button>
{tapdError && <p className="text-xs text-red-400 break-all">{tapdError}</p>}
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
<button
onClick={() => setSettingsOpen(false)}
className="px-5 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-bg-hover transition-colors"
>
{t.settings.cancel}
</button>
<button
onClick={handleSave}
className="px-6 py-2 rounded-md bg-accent hover:bg-accent-hover text-white text-sm font-bold shadow-lg shadow-accent/20 transition-colors"
>
{t.settings.save}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { useStore } from '../stores'
import { X, Keyboard } from 'lucide-react'
export function ShortcutsModal() {
const { t, locale, setShortcutsOpen } = useStore()
// Dynamic i18n reference, synced with toolbar
const data = [
{ category: locale === 'zh' ? '通用' : 'General', items: [
{ keys: 'Ctrl+V', desc: locale === 'zh' ? '粘贴截图' : 'Paste screenshot' },
{ keys: 'Ctrl+N', desc: locale === 'zh' ? '新建 Bug' : 'New Bug' },
{ keys: 'Ctrl+Enter', desc: locale === 'zh' ? '切换编辑/预览模式' : 'Toggle edit/preview' },
{ keys: 'Delete', desc: locale === 'zh' ? '删除选中标注' : 'Delete annotation' },
{ keys: 'Ctrl+Z', desc: t.editor.tools.undo },
{ keys: 'Ctrl+Shift+Z', desc: t.editor.tools.redo },
]},
{ category: locale === 'zh' ? '标注工具' : 'Annotation Tools', items: [
{ keys: 'D', desc: t.editor.tools.drag },
{ keys: 'V', desc: t.editor.tools.select },
{ keys: 'R', desc: t.editor.tools.rect },
{ keys: 'A', desc: t.editor.tools.arrow },
{ keys: 'T', desc: t.editor.tools.text },
{ keys: 'N', desc: t.editor.tools.number },
{ keys: 'H', desc: t.editor.tools.highlight },
{ keys: 'P', desc: t.editor.tools.pen },
{ keys: 'M', desc: t.editor.tools.mosaic },
]},
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShortcutsOpen(false)}>
<div className="w-[400px] bg-bg-card border border-border rounded-2xl shadow-2xl" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-text-muted" />
<h2 className="text-lg font-semibold text-text-primary">
{locale === 'zh' ? '快捷键' : 'Keyboard Shortcuts'}
</h2>
</div>
<button
onClick={() => setShortcutsOpen(false)}
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-5 max-h-[60vh] overflow-y-auto">
{data.map((group) => (
<div key={group.category}>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">{group.category}</h3>
<div className="space-y-2">
{group.items.map((item) => (
<div key={item.keys} className="flex items-center justify-between">
<span className="text-sm text-text-secondary">{item.desc}</span>
<div className="flex gap-1">
{item.keys.split('+').map((k) => (
<kbd key={k} className="px-2 py-0.5 bg-bg-input border border-border rounded text-xs text-text-primary font-mono">
{k}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,461 @@
import { useState, useRef, useEffect } from 'react'
import { useStore, type BugStatus, type FilterTab } from '../stores'
import { useVirtualList } from '../hooks/useVirtualList'
import { ConfirmDialog } from './ConfirmDialog'
import { ZentaoModal } from './ZentaoModal'
import { JiraModal } from './JiraModal'
import { LinearModal } from './LinearModal'
import { TapdModal } from './TapdModal'
import { Search, Camera, Trash2, ExternalLink, ChevronDown, CheckSquare, Square, ListChecks, Plus } from 'lucide-react'
// Status color mapping
const statusColorMap: Record<BugStatus, string> = {
pending: 'bg-red-500/20 text-red-400',
annotating: 'bg-yellow-500/20 text-yellow-400',
generated: 'bg-blue-500/20 text-blue-400',
fixed: 'bg-green-500/20 text-green-400',
closed: 'bg-gray-500/20 text-gray-400',
}
// Time formatting
function timeAgo(dateStr: string, suffix: string): string {
const normalized = dateStr.includes('T') ? dateStr : dateStr.replace(' ', 'T') + 'Z'
const diff = Date.now() - new Date(normalized).getTime()
const minutes = Math.max(0, Math.floor(diff / 60000))
const isZh = suffix === '前'
if (minutes < 1) return isZh ? '刚刚' : 'just now'
if (minutes < 60) return isZh ? `${minutes}分钟${suffix}` : `${minutes}m ${suffix}`
const hours = Math.floor(minutes / 60)
if (hours < 24) return isZh ? `${hours}小时${suffix}` : `${hours}h ${suffix}`
const days = Math.floor(hours / 24)
return isZh ? `${days}${suffix}` : `${days}d ${suffix}`
}
export function Sidebar({ width }: { width?: number }) {
const {
t,
locale,
bugs,
selectedBugId,
filterTab,
searchQuery,
selectBug,
setFilterTab,
setSearchQuery,
createBug,
deleteBug,
updateBug,
batchUpdateStatus,
batchDeleteBugs,
clearSelection,
} = useStore()
const zh = locale === 'zh'
const [deleteTarget, setDeleteTarget] = useState<{ id: string; number: number } | null>(null)
const [zentaoOpen, setZentaoOpen] = useState(false)
const [jiraOpen, setJiraOpen] = useState(false)
const [linearOpen, setLinearOpen] = useState(false)
const [tapdOpen, setTapdOpen] = useState(false)
const [importDropdown, setImportDropdown] = useState(false)
const importDropdownRef = useRef<HTMLDivElement>(null)
// Single bug quick status toggle
const [statusDropdown, setStatusDropdown] = useState<string | null>(null)
const [statusDropdownPos, setStatusDropdownPos] = useState({ x: 0, y: 0 })
const statusDropdownRef = useRef<HTMLDivElement>(null)
// Close import dropdown on outside click
useEffect(() => {
if (!importDropdown) return
const handler = (e: MouseEvent) => {
if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) {
setImportDropdown(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [importDropdown])
// Close status dropdown on outside click
useEffect(() => {
if (!statusDropdown) return
const handler = (e: MouseEvent) => {
if (statusDropdownRef.current && !statusDropdownRef.current.contains(e.target as Node)) {
setStatusDropdown(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [statusDropdown])
const statusOptions: BugStatus[] = ['pending', 'fixed', 'closed']
// Batch mode
const [batchMode, setBatchMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchConfirm, setBatchConfirm] = useState(false)
const BUG_ITEM_HEIGHT = 76
// Stats
const pendingCount = bugs.filter((b) => b.status === 'pending' || b.status === 'annotating').length
const fixedCount = bugs.filter((b) => b.status === 'fixed').length
// Filter
const filteredBugs = bugs.filter((bug) => {
if (filterTab === 'pending') return bug.status === 'pending' || bug.status === 'annotating'
if (filterTab === 'fixed') return bug.status === 'fixed'
return true
}).filter((bug) => {
if (!searchQuery) return true
const q = searchQuery.toLowerCase()
return bug.title.toLowerCase().includes(q) || String(bug.number).includes(q)
})
const {
containerRef: virtualContainerRef,
onScroll: onVirtualScroll,
startIndex,
endIndex,
totalHeight,
offsetY,
scrollToIndex,
} = useVirtualList({
itemCount: filteredBugs.length,
itemHeight: BUG_ITEM_HEIGHT,
})
// Auto-scroll to selected bug when it changes
useEffect(() => {
if (!selectedBugId || batchMode) return
const idx = filteredBugs.findIndex(b => b.id === selectedBugId)
if (idx >= 0) scrollToIndex(idx)
}, [selectedBugId])
const tabs: { key: FilterTab; label: string; count: number }[] = [
{ key: 'all', label: t.sidebar.filterAll, count: bugs.length },
{ key: 'pending', label: t.sidebar.filterPending, count: pendingCount },
{ key: 'fixed', label: t.sidebar.filterFixed, count: fixedCount },
]
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const s = new Set(prev)
s.has(id) ? s.delete(id) : s.add(id)
return s
})
}
const toggleSelectAll = () => {
const allIds = filteredBugs.map(b => b.id)
const allSelected = allIds.every(id => selectedIds.has(id))
setSelectedIds(allSelected ? new Set() : new Set(allIds))
}
const exitBatchMode = () => {
setBatchMode(false)
setSelectedIds(new Set())
}
const handleBatchStatus = async (status: BugStatus) => {
if (selectedIds.size === 0) return
await batchUpdateStatus([...selectedIds], status)
exitBatchMode()
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return
await batchDeleteBugs([...selectedIds])
exitBatchMode()
}
return (
<aside style={{ width: width ?? 240 }} className="bg-bg-sidebar border-r border-border flex flex-col shrink-0">
{/* Search + New */}
<div className="p-3 space-y-2">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder={t.sidebar.searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 bg-bg-input border border-border rounded-xl text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
/>
</div>
{/* New + Batch + Import in one row */}
<div className="flex gap-2 items-center">
<button
onClick={() => createBug()}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-xl transition-colors"
>
<Plus className="w-4 h-4" />
{t.sidebar.newBug.replace('+ ', '')}
</button>
{/* Batch mode */}
<button
onClick={() => batchMode ? exitBatchMode() : setBatchMode(true)}
className={`p-2.5 rounded-xl border transition-colors ${
batchMode ? 'bg-accent/20 text-accent border-accent/30' : 'bg-bg-input border-border text-text-muted hover:text-text-secondary hover:bg-bg-hover'
}`}
title={t.sidebar.batchAction}
>
<ListChecks className="w-4 h-4" />
</button>
{/* Import */}
<div className="relative" ref={importDropdownRef}>
<button
onClick={() => setImportDropdown(!importDropdown)}
className="flex items-center gap-0.5 p-2.5 bg-bg-input border border-border text-text-muted hover:text-text-secondary hover:bg-bg-hover rounded-xl transition-colors"
title={zh ? '从外部导入' : 'Import from external'}
>
<ExternalLink className="w-4 h-4" />
<ChevronDown className="w-2.5 h-2.5" />
</button>
{importDropdown && (
<div className="absolute top-full left-0 mt-1 w-40 bg-bg-card border border-border rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setZentaoOpen(true); setImportDropdown(false) }}
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
{zh ? '从禅道导入' : 'From Zentao'}
</button>
<button
onClick={() => { setJiraOpen(true); setImportDropdown(false) }}
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
{zh ? '从 Jira 导入' : 'From Jira'}
</button>
<button
onClick={() => { setLinearOpen(true); setImportDropdown(false) }}
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
{zh ? '从 Linear 导入' : 'From Linear'}
</button>
<button
onClick={() => { setTapdOpen(true); setImportDropdown(false) }}
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
{zh ? '从 TAPD 导入' : 'From TAPD'}
</button>
</div>
)}
</div>
</div>
</div>
{/* Filter tabs */}
<div className="flex px-3 gap-1 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setFilterTab(tab.key)}
className={`flex-1 py-2 text-xs text-center transition-colors relative ${
filterTab === tab.key
? 'text-accent'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{tab.label} {tab.count}
{filterTab === tab.key && (
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 bg-accent rounded-full" />
)}
</button>
))}
</div>
{/* Bug list (virtual scroll) */}
<div
ref={virtualContainerRef}
onScroll={onVirtualScroll}
className="flex-1 overflow-y-auto"
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ position: 'absolute', top: offsetY, left: 0, right: 0 }}>
{filteredBugs.slice(startIndex, endIndex + 1).map((bug) => (
<div
key={bug.id}
style={{ height: BUG_ITEM_HEIGHT, boxSizing: 'border-box' }}
onClick={() => batchMode ? toggleSelect(bug.id) : selectBug(bug.id)}
className={`w-full text-left px-4 py-3 border-l-[3px] transition-colors cursor-pointer group relative ${
batchMode && selectedIds.has(bug.id)
? 'border-l-accent bg-accent/10'
: selectedBugId === bug.id && !batchMode
? 'border-l-accent bg-accent/10'
: 'border-l-transparent hover:bg-bg-hover'
}`}
>
{/* Number + Status */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
{batchMode && (
selectedIds.has(bug.id)
? <CheckSquare className="w-3.5 h-3.5 text-accent" />
: <Square className="w-3.5 h-3.5 text-text-muted" />
)}
<span className="text-xs text-text-muted">#{String(bug.number).padStart(3, '0')}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
if (statusDropdown === bug.id) {
setStatusDropdown(null)
} else {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setStatusDropdownPos({ x: rect.right + 4, y: rect.bottom })
setStatusDropdown(bug.id)
}
}}
className={`text-[10px] px-1.5 py-0.5 rounded-full cursor-pointer hover:opacity-80 transition-opacity ${statusColorMap[bug.status]}`}
>
{t.status[bug.status]}
</button>
</div>
{/* Title */}
<p className="text-sm text-text-primary truncate mb-1">{bug.title || (zh ? '未命名 Bug' : 'Untitled Bug')}</p>
{/* Screenshot count + time + delete */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="flex items-center gap-1">
<Camera className="w-3 h-3" />
{bug.screenshots.length}{t.sidebar.screenshots}
</span>
<span>·</span>
<span>{timeAgo(bug.createdAt, t.sidebar.timeAgo)}</span>
</div>
{!batchMode && (
<button
onClick={(e) => {
e.stopPropagation()
setDeleteTarget({ id: bug.id, number: bug.number })
}}
className="p-0.5 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Batch action bar */}
{batchMode && (
<div className="px-3 py-2 border-t border-border bg-bg-input space-y-2">
<div className="flex items-center justify-between">
<button
onClick={toggleSelectAll}
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-accent transition-colors"
>
{filteredBugs.length > 0 && filteredBugs.every(b => selectedIds.has(b.id))
? <CheckSquare className="w-3.5 h-3.5 text-accent" />
: <Square className="w-3.5 h-3.5" />}
{zh ? '全选' : 'Select All'}
</button>
<span className="text-xs text-text-muted">
{zh ? `已选 ${selectedIds.size}` : `${selectedIds.size} selected`}
</span>
</div>
<div className="flex gap-1.5">
<select
disabled={selectedIds.size === 0}
defaultValue=""
onChange={(e) => { if (e.target.value) { handleBatchStatus(e.target.value as BugStatus); e.target.value = '' } }}
className="flex-1 px-2 py-1.5 bg-bg-input border border-border rounded-lg text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="" disabled>{zh ? '修改状态' : 'Set Status'}</option>
{(['pending', 'fixed', 'closed'] as BugStatus[]).map(s => (
<option key={s} value={s}>{t.status[s]}</option>
))}
</select>
<button
onClick={() => setBatchConfirm(true)}
disabled={selectedIds.size === 0}
className="px-2 py-1.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
{zh ? '删除' : 'Delete'}
</button>
</div>
</div>
)}
{/* Delete bug confirmation */}
<ConfirmDialog
open={!!deleteTarget}
title={zh ? '删除 Bug' : 'Delete Bug'}
message={zh
? `确定删除 Bug #${String(deleteTarget?.number ?? 0).padStart(3, '0')}?此操作不可撤销。`
: `Delete Bug #${String(deleteTarget?.number ?? 0).padStart(3, '0')}? This cannot be undone.`}
confirmText={zh ? '确认删除' : 'Delete'}
cancelText={zh ? '取消' : 'Cancel'}
onConfirm={() => { if (deleteTarget) deleteBug(deleteTarget.id); setDeleteTarget(null) }}
onCancel={() => setDeleteTarget(null)}
/>
{/* Batch delete confirmation */}
<ConfirmDialog
open={batchConfirm}
title={zh ? '批量删除' : 'Batch Delete'}
message={zh
? `确定删除选中的 ${selectedIds.size} 个 Bug?此操作不可撤销。`
: `Delete ${selectedIds.size} selected bugs? This cannot be undone.`}
confirmText={zh ? '确认删除' : 'Delete'}
cancelText={zh ? '取消' : 'Cancel'}
onConfirm={() => { handleBatchDelete(); setBatchConfirm(false) }}
onCancel={() => setBatchConfirm(false)}
/>
{/* Quick status toggle popover */}
{statusDropdown && (
<div
ref={statusDropdownRef}
className="fixed bg-bg-card border border-border rounded-md shadow-xl z-50 py-0.5 w-auto"
style={{ left: statusDropdownPos.x, top: statusDropdownPos.y }}
>
{statusOptions.map((s) => (
<button
key={s}
onClick={(e) => {
e.stopPropagation()
const bugId = statusDropdown
if (bugId) {
const bug = bugs.find(b => b.id === bugId)
if (bug && s !== bug.status) {
updateBug(bugId, { status: s })
// 状态变化时,自动跳转到下一个待处理的 bug
const nextPending = bugs.find(b => b.id !== bugId && (b.status === 'pending' || b.status === 'annotating'))
if (nextPending) {
selectBug(nextPending.id)
} else {
// 没有待处理的 bug,清空选中状态
clearSelection()
}
}
}
setStatusDropdown(null)
}}
className={`block text-left whitespace-nowrap px-2.5 py-1 text-[11px] transition-colors ${
bugs.find(b => b.id === statusDropdown)?.status === s
? 'bg-accent/10 text-accent'
: 'text-text-secondary hover:bg-bg-hover'
}`}
>
<span className="inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle"
style={{ backgroundColor: s === 'pending' ? '#ef4444' : s === 'fixed' ? '#22c55e' : '#6b7280' }} />
{t.status[s]}
</button>
))}
</div>
)}
{/* Import modals */}
{zentaoOpen && <ZentaoModal onClose={() => setZentaoOpen(false)} />}
{jiraOpen && <JiraModal onClose={() => setJiraOpen(false)} />}
{linearOpen && <LinearModal onClose={() => setLinearOpen(false)} />}
{tapdOpen && <TapdModal onClose={() => setTapdOpen(false)} />}
</aside>
)
}
@@ -0,0 +1,29 @@
import { useStore } from '../stores'
import { Folder } from 'lucide-react'
export function StatusBar() {
const { bugs, settings, locale } = useStore()
const zh = locale === 'zh'
const total = bugs.length
const pending = bugs.filter((b) => b.status === 'pending' || b.status === 'annotating').length
return (
<footer className="h-8 bg-bg-primary border-t border-border flex items-center justify-between px-4 text-xs text-text-muted shrink-0">
{/* Left: workspace status + stats */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
<span>{zh ? '工作空间活跃' : 'Workspace Active'}</span>
</div>
<span>{zh ? `${total} 个 Bug · ${pending} 待处理` : `${total} bugs · ${pending} pending`}</span>
</div>
{/* Right: storage path */}
<div className="flex items-center gap-1.5">
<Folder className="w-3 h-3" />
<span>{settings.dataDir || settings._dataDir || '~/.bugpack/data/'}</span>
</div>
</footer>
)
}
@@ -0,0 +1,266 @@
import { useState, useEffect, useRef } from 'react'
import { useStore } from '../stores'
import { api } from '../api'
import { X, Download, RefreshCw, ExternalLink, AlertCircle, CheckSquare, Square } from 'lucide-react'
interface TapdBug {
id: string
title: string
severity: string
priority: string
status: string
reporter: string
currentOwner: string
created: string
}
export function TapdModal({ onClose }: { onClose: () => void }) {
const { locale, currentProjectId, fetchBugs, settings } = useStore()
const zh = locale === 'zh'
const hasImported = useRef(false)
const [bugs, setBugs] = useState<TapdBug[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [importing, setImporting] = useState<Set<string>>(new Set())
const [imported, setImported] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchImporting, setBatchImporting] = useState(false)
const loadBugs = async () => {
if (!settings.tapdWorkspaceId) {
setError(zh ? '请先在设置中填写 TAPD 项目 IDworkspace_id' : 'Please set TAPD workspace_id in Settings first')
setLoading(false)
return
}
setLoading(true)
setError('')
try {
const res = await api.tapd.getBugs()
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
setBugs(res.bugs || [])
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadBugs()
}, [])
const handleImport = async (id: string) => {
setImporting(prev => new Set(prev).add(id))
try {
const res = await api.tapd.importBug(id, currentProjectId)
if (!res.ok) throw new Error('Import failed')
setImported(prev => new Set(prev).add(id))
hasImported.current = true
} catch {
// ignore
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
}
}
const toggleSelect = (id: string) => {
setSelected(prev => {
const s = new Set(prev)
s.has(id) ? s.delete(id) : s.add(id)
return s
})
}
const toggleSelectAll = () => {
const importable = bugs.filter(b => !imported.has(b.id)).map(b => b.id)
const allSelected = importable.every(id => selected.has(id))
setSelected(allSelected ? new Set() : new Set(importable))
}
const handleBatchImport = async () => {
if (selected.size === 0) return
setBatchImporting(true)
const ids = [...selected].filter(id => !imported.has(id))
for (const id of ids) {
setImporting(prev => new Set(prev).add(id))
try {
const res = await api.tapd.importBug(id, currentProjectId)
if (res.ok) setImported(prev => new Set(prev).add(id))
} catch {
// skip failed
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
}
}
setSelected(new Set())
hasImported.current = true
setBatchImporting(false)
fetchBugs()
onClose()
}
const sevColor = (s: string) => {
if (s === 'fatal' || s === '致命') return 'text-red-400'
if (s === 'serious' || s === '严重') return 'text-orange-400'
if (s === 'normal' || s === '一般') return 'text-yellow-400'
return 'text-text-muted'
}
const statusText = (s: string) => {
const map: Record<string, string> = {
new: zh ? '新建' : 'New',
assigned: zh ? '已指派' : 'Assigned',
reopened: zh ? '重新打开' : 'Reopened',
resolved: zh ? '已解决' : 'Resolved',
closed: zh ? '已关闭' : 'Closed',
}
return map[s] || s
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<ExternalLink className="w-5 h-5 text-cyan-400" />
<h2 className="text-lg font-semibold text-text-primary">
{zh ? '从 TAPD 导入' : 'Import from TAPD'}
</h2>
{settings.tapdWorkspaceId && (
<span className="ml-2 px-2 py-0.5 text-xs text-cyan-400 bg-cyan-400/10 rounded">
#{settings.tapdWorkspaceId}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => loadBugs()}
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{error && (
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div className="text-sm text-red-400">
<p>{error}</p>
<p className="text-xs text-red-400/60 mt-1">
{zh ? '请检查设置中的 TAPD 配置' : 'Check TAPD settings'}
</p>
</div>
</div>
)}
{loading && !error && (
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
{zh ? '加载中...' : 'Loading...'}
</div>
)}
{/* Bug list */}
{!loading && !error && bugs.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
<p>{zh ? '该项目下没有待处理的 Bug' : 'No open bugs in this project'}</p>
</div>
)}
{!loading && bugs.length > 0 && (
<div className="divide-y divide-border">
{bugs.map(bug => (
<div key={bug.id} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
<button
onClick={() => toggleSelect(bug.id)}
className={`shrink-0 ${imported.has(bug.id) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-cyan-400'}`}
disabled={imported.has(bug.id)}
>
{selected.has(bug.id) ? <CheckSquare className="w-4 h-4 text-cyan-400" /> : <Square className="w-4 h-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs text-text-muted">#{bug.id}</span>
<span className={`text-xs font-medium ${sevColor(bug.severity)}`}>{bug.severity || bug.priority}</span>
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{statusText(bug.status)}</span>
</div>
<p className="text-sm text-text-primary truncate">{bug.title}</p>
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
{bug.currentOwner && <span>{bug.currentOwner}</span>}
{bug.created && <span>{new Date(bug.created).toLocaleString()}</span>}
</div>
</div>
<button
onClick={() => handleImport(bug.id)}
disabled={importing.has(bug.id) || imported.has(bug.id)}
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
imported.has(bug.id)
? 'bg-green-500/20 text-green-400 cursor-default'
: importing.has(bug.id)
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-cyan-400/20 text-cyan-400 hover:bg-cyan-400/30'
}`}
>
{imported.has(bug.id) ? (
zh ? '已导入' : 'Imported'
) : importing.has(bug.id) ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
<div className="flex items-center gap-3">
{bugs.length > 0 && (
<button
onClick={toggleSelectAll}
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-cyan-400 transition-colors"
>
{bugs.filter(b => !imported.has(b.id)).every(b => selected.has(b.id)) && bugs.some(b => !imported.has(b.id))
? <CheckSquare className="w-3.5 h-3.5 text-cyan-400" />
: <Square className="w-3.5 h-3.5" />}
{zh ? '全选' : 'Select All'}
</button>
)}
<span className="text-xs text-text-muted">
{zh
? `${bugs.length} 个 Bug${selected.size > 0 ? `,已选 ${selected.size}` : ''}`
: `${bugs.length} bugs${selected.size > 0 ? `, ${selected.size} selected` : ''}`}
</span>
</div>
{selected.size > 0 && (
<button
onClick={handleBatchImport}
disabled={batchImporting}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
batchImporting
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-cyan-400 text-white hover:bg-cyan-500'
}`}
>
{batchImporting ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
)}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,339 @@
import { useState, useEffect, useRef } from 'react'
import { useStore } from '../stores'
import { api } from '../api'
import { X, Download, RefreshCw, ExternalLink, AlertCircle, ChevronDown, CheckSquare, Square } from 'lucide-react'
interface ZentaoBug {
id: number
title: string
severity: number
pri: number
status: string
openedBy?: { realname?: string }
openedDate?: string
}
interface ZentaoProduct {
id: number
name: string
}
export function ZentaoModal({ onClose }: { onClose: () => void }) {
const { locale, currentProjectId, fetchBugs, settings, saveSettings } = useStore()
const zh = locale === 'zh'
const hasImported = useRef(false)
const [products, setProducts] = useState<ZentaoProduct[]>([])
const [selectedProductId, setSelectedProductId] = useState(settings.zentaoProductId || '')
const [bugs, setBugs] = useState<ZentaoBug[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [importing, setImporting] = useState<Set<number>>(new Set())
const [imported, setImported] = useState<Set<number>>(new Set())
const [selected, setSelected] = useState<Set<number>>(new Set())
const [batchImporting, setBatchImporting] = useState(false)
const [step, setStep] = useState<'products' | 'bugs'>(settings.zentaoProductId ? 'bugs' : 'products')
// Load product list
const loadProducts = async () => {
setLoading(true)
setError('')
try {
const res = await api.zentao.getProducts()
if (!res.ok) throw new Error(res.error || 'Failed to fetch products')
setProducts(res.products || [])
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
// Load bug list
const loadBugs = async (productId?: string) => {
const pid = productId || selectedProductId
if (!pid) { setStep('products'); loadProducts(); return }
setLoading(true)
setError('')
try {
// Save product ID to settings first
if (pid !== settings.zentaoProductId) {
await saveSettings({ zentaoProductId: pid })
}
const res = await api.zentao.getBugs()
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
setBugs(res.bugs || [])
setStep('bugs')
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (settings.zentaoProductId) {
loadBugs(settings.zentaoProductId)
} else {
loadProducts()
}
}, [])
// Select product
const selectProduct = (id: number) => {
const pid = String(id)
setSelectedProductId(pid)
loadBugs(pid)
}
// Import single bug
const handleImport = async (bugId: number) => {
setImporting(prev => new Set(prev).add(bugId))
try {
const res = await api.zentao.importBug(bugId, currentProjectId)
if (!res.ok) throw new Error('Import failed')
setImported(prev => new Set(prev).add(bugId))
hasImported.current = true
} catch {
// ignore
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(bugId); return s })
}
}
// Toggle selection
const toggleSelect = (bugId: number) => {
setSelected(prev => {
const s = new Set(prev)
s.has(bugId) ? s.delete(bugId) : s.add(bugId)
return s
})
}
// Toggle select all (excluding imported)
const toggleSelectAll = () => {
const importable = bugs.filter(b => !imported.has(b.id)).map(b => b.id)
const allSelected = importable.every(id => selected.has(id))
setSelected(allSelected ? new Set() : new Set(importable))
}
// Batch import
const handleBatchImport = async () => {
if (selected.size === 0) return
setBatchImporting(true)
const ids = [...selected].filter(id => !imported.has(id))
for (const bugId of ids) {
setImporting(prev => new Set(prev).add(bugId))
try {
const res = await api.zentao.importBug(bugId, currentProjectId)
if (res.ok) setImported(prev => new Set(prev).add(bugId))
} catch {
// skip failed
} finally {
setImporting(prev => { const s = new Set(prev); s.delete(bugId); return s })
}
}
setSelected(new Set())
hasImported.current = true
setBatchImporting(false)
fetchBugs()
onClose()
}
// Severity color
const sevColor = (s: number) => {
if (s <= 1) return 'text-red-400'
if (s === 2) return 'text-orange-400'
if (s === 3) return 'text-yellow-400'
return 'text-text-muted'
}
const statusText = (s: string) => {
const map: Record<string, string> = { active: zh ? '激活' : 'Active', resolved: zh ? '已解决' : 'Resolved', closed: zh ? '已关闭' : 'Closed' }
return map[s] || s
}
const curProduct = products.find(p => String(p.id) === selectedProductId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<ExternalLink className="w-5 h-5 text-accent" />
<h2 className="text-lg font-semibold text-text-primary">
{zh ? '从禅道导入' : 'Import from Zentao'}
</h2>
{/* Product switcher */}
{step === 'bugs' && (
<button
onClick={() => { setStep('products'); loadProducts() }}
className="ml-2 flex items-center gap-1 px-2 py-0.5 text-xs text-accent bg-accent/10 rounded hover:bg-accent/20 transition-colors"
>
{curProduct?.name || `ID:${selectedProductId}`}
<ChevronDown className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => step === 'bugs' ? loadBugs() : loadProducts()}
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{error && (
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div className="text-sm text-red-400">
<p>{error}</p>
<p className="text-xs text-red-400/60 mt-1">
{zh ? '请检查设置中的禅道配置' : 'Check Zentao settings'}
</p>
</div>
</div>
)}
{loading && !error && (
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
{zh ? '加载中...' : 'Loading...'}
</div>
)}
{/* Product selection */}
{!loading && step === 'products' && !error && (
<div className="p-6">
<p className="text-sm text-text-secondary mb-3">
{zh ? '选择禅道项目:' : 'Select a Zentao project:'}
</p>
{products.length === 0 ? (
<p className="text-sm text-text-muted text-center py-8">
{zh ? '没有找到项目(当前账号可能无权限)' : 'No products found (no permission?)'}
</p>
) : (
<div className="space-y-2">
{products.map(p => (
<button
key={p.id}
onClick={() => selectProduct(p.id)}
className="w-full text-left px-4 py-3 bg-bg-input border border-border rounded-lg hover:border-accent hover:bg-accent/5 transition-colors"
>
<span className="text-xs text-text-muted mr-2">#{p.id}</span>
<span className="text-sm text-text-primary">{p.name}</span>
</button>
))}
</div>
)}
</div>
)}
{/* Bug list */}
{!loading && step === 'bugs' && !error && bugs.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
<p>{zh ? '该项目下没有指派给你的 Bug' : 'No bugs assigned to you'}</p>
</div>
)}
{!loading && step === 'bugs' && bugs.length > 0 && (
<div className="divide-y divide-border">
{bugs.map(bug => (
<div key={bug.id} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
{/* Checkbox */}
<button
onClick={() => toggleSelect(bug.id)}
className={`shrink-0 ${imported.has(bug.id) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-accent'}`}
disabled={imported.has(bug.id)}
>
{selected.has(bug.id) ? <CheckSquare className="w-4 h-4 text-accent" /> : <Square className="w-4 h-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs text-text-muted">#{bug.id}</span>
<span className={`text-xs font-medium ${sevColor(bug.severity)}`}>S{bug.severity}</span>
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{statusText(bug.status)}</span>
</div>
<p className="text-sm text-text-primary truncate">{bug.title}</p>
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
{bug.openedBy?.realname && <span>{bug.openedBy.realname}</span>}
{bug.openedDate && <span>{new Date(bug.openedDate).toLocaleString()}</span>}
</div>
</div>
<button
onClick={() => handleImport(bug.id)}
disabled={importing.has(bug.id) || imported.has(bug.id)}
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
imported.has(bug.id)
? 'bg-green-500/20 text-green-400 cursor-default'
: importing.has(bug.id)
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-accent/20 text-accent hover:bg-accent/30'
}`}
>
{imported.has(bug.id) ? (
zh ? '已导入' : 'Imported'
) : importing.has(bug.id) ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
<div className="flex items-center gap-3">
{step === 'bugs' && bugs.length > 0 && (
<button
onClick={toggleSelectAll}
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-accent transition-colors"
>
{bugs.filter(b => !imported.has(b.id)).every(b => selected.has(b.id)) && bugs.some(b => !imported.has(b.id))
? <CheckSquare className="w-3.5 h-3.5 text-accent" />
: <Square className="w-3.5 h-3.5" />}
{zh ? '全选' : 'Select All'}
</button>
)}
<span className="text-xs text-text-muted">
{step === 'bugs'
? (zh
? `指派给我 ${bugs.length} 个 Bug${selected.size > 0 ? `,已选 ${selected.size}` : ''}`
: `${bugs.length} bugs assigned to me${selected.size > 0 ? `, ${selected.size} selected` : ''}`)
: (zh ? `${products.length} 个项目` : `${products.length} projects`)}
</span>
</div>
{step === 'bugs' && selected.size > 0 && (
<button
onClick={handleBatchImport}
disabled={batchImporting}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
batchImporting
? 'bg-bg-input text-text-muted cursor-wait'
: 'bg-accent text-white hover:bg-accent-hover'
}`}
>
{batchImporting ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
) : (
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
)}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,52 @@
import { useEffect } from 'react'
import { useStore } from '../stores'
// Global keyboard shortcuts
export function useKeyboard() {
const { createBug, setViewMode, viewMode, selectedBugId } = useStore()
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable
// Ctrl combos (regardless of input focus)
if (e.ctrlKey || e.metaKey) {
if (e.key === 'n' || e.key === 'N') {
e.preventDefault()
createBug()
return
}
if (e.key === 'Enter' && selectedBugId) {
e.preventDefault()
setViewMode(viewMode === 'edit' ? 'preview' : 'edit')
return
}
}
// Single key shortcuts (only when not in input)
if (isInput) return
// Tool switching via custom events, listened by EditorArea
const toolMap: Record<string, string> = {
d: 'drag',
v: 'select',
r: 'rect',
a: 'arrow',
t: 'text',
n: 'number',
h: 'highlight',
p: 'pen',
m: 'mosaic',
}
const tool = toolMap[e.key.toLowerCase()]
if (tool) {
window.dispatchEvent(new CustomEvent('bugpack:tool', { detail: tool }))
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [createBug, setViewMode, viewMode, selectedBugId])
}
@@ -0,0 +1,66 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
interface UseVirtualListOptions {
itemCount: number
itemHeight: number
overscan?: number // extra items rendered above/below
}
export function useVirtualList({ itemCount, itemHeight, overscan = 5 }: UseVirtualListOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const [scrollTop, setScrollTop] = useState(0)
const [containerHeight, setContainerHeight] = useState(0)
const onScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop)
}
}, [])
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new ResizeObserver(() => {
setContainerHeight(el.clientHeight)
})
observer.observe(el)
setContainerHeight(el.clientHeight)
return () => observer.disconnect()
}, [])
const { startIndex, endIndex, totalHeight, offsetY } = useMemo(() => {
const totalHeight = itemCount * itemHeight
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
const visibleCount = Math.ceil(containerHeight / itemHeight)
const end = Math.min(itemCount - 1, start + visibleCount + overscan * 2)
return {
startIndex: start,
endIndex: end,
totalHeight,
offsetY: start * itemHeight,
}
}, [itemCount, itemHeight, containerHeight, scrollTop, overscan])
// Scroll to given index
const scrollToIndex = useCallback((index: number) => {
const el = containerRef.current
if (!el) return
const targetTop = index * itemHeight
const targetBottom = targetTop + itemHeight
if (targetTop < el.scrollTop) {
el.scrollTop = targetTop
} else if (targetBottom > el.scrollTop + el.clientHeight) {
el.scrollTop = targetBottom - el.clientHeight
}
}, [itemHeight])
return {
containerRef,
onScroll,
startIndex,
endIndex,
totalHeight,
offsetY,
scrollToIndex,
}
}
+139
View File
@@ -0,0 +1,139 @@
export const en = {
app: {
name: 'BugPack',
version: 'v1.0.0',
},
nav: {
project: 'Project',
settings: 'Settings',
shortcuts: 'Shortcuts',
},
sidebar: {
searchPlaceholder: 'Search bugs...',
newBug: '+ New Bug',
filterAll: 'All',
filterPending: 'Pending',
filterFixed: 'Fixed',
batchAction: 'Batch Actions',
screenshots: 'screenshots',
timeAgo: 'ago',
},
status: {
pending: 'Pending',
annotating: 'Annotating',
generated: 'Generated',
fixed: 'Fixed',
closed: 'Closed',
},
priority: {
high: 'High',
medium: 'Medium',
low: 'Low',
},
editor: {
tools: {
drag: 'Drag',
select: 'Select',
rect: 'Rectangle',
arrow: 'Arrow',
text: 'Text',
number: 'Number',
highlight: 'Highlight',
pen: 'Pen',
mosaic: 'Mosaic',
undo: 'Undo',
redo: 'Redo',
reset: 'Reset All Annotations',
},
lineWidth: {
thin: 'Thin',
medium: 'Medium',
thick: 'Thick',
},
compare: 'Compare',
compareLeft: 'Current',
compareRight: 'Expected',
fitWindow: 'Fit',
emptyTitle: 'Ctrl+V to paste screenshot',
emptySubtitle: 'or drag and drop images here',
emptyFormat: 'Supports PNG / JPG / GIF',
copyImage: 'Copy Image',
copySuccess: 'Copied to clipboard',
copyFail: 'Copy failed',
},
evidence: {
title: 'Evidence',
addFile: 'Add File',
annotated: 'Annotated',
notAnnotated: 'Not annotated',
},
panel: {
bugInfo: 'Bug Details',
title: 'Title',
titlePlaceholder: 'Enter bug title...',
description: 'Description',
descriptionPlaceholder: 'Describe the bug in detail...',
priority: 'Priority',
statusLabel: 'Status',
envInfo: 'Environment Context',
pagePath: 'Page Path',
pagePathPlaceholder: 'e.g. /login',
device: 'Device / Resolution',
devicePlaceholder: 'e.g. iPhone 14 Pro 390x844',
browser: 'Browser',
browserPlaceholder: 'e.g. Chrome 120.0',
relatedFiles: 'Suggested Files to Fix',
addFilePath: '+ Add file path',
filePathPlaceholder: 'Enter file path...',
smartSuggest: 'Smart Suggest',
annotations: 'Annotations',
generateBtn: 'Generate AI Instructions',
generateShortcut: 'Ctrl+Enter',
copyBtn: 'Copy',
exportBtn: 'Export MD',
},
preview: {
backToEdit: 'Back to Edit',
copy: 'Copy to Clipboard',
export: 'Export .md',
regenerate: 'Re-generate',
contents: 'Contents',
screenshots: 'Issue Screenshots',
annotationsSection: 'Annotations',
environment: 'Environment Info',
requirements: 'Requirements',
relatedFilesSection: 'Related Files',
aiInstructions: 'AI Agent Instructions',
},
statusBar: {
total: 'Total Bugs',
pending: 'Pending',
fixed: 'Fixed',
mcp: 'MCP',
storage: 'Storage',
},
empty: {
title: 'No bugs yet, awesome!',
subtitle: 'Ctrl+V to paste and create quickly',
createFirst: '+ Create First Bug',
tip: 'Tip: Copy bug screenshots from chat apps, then Ctrl+V to paste',
},
settings: {
title: 'Settings',
projectConfig: 'Project Config',
projectName: 'Project Name',
rootDir: 'Root Directory',
dataDir: 'Data Directory',
browse: 'Browse',
integrations: 'Integrations',
appearance: 'Appearance',
shortcuts: 'Shortcuts',
about: 'About',
cancel: 'Cancel',
save: 'Save',
},
}
@@ -0,0 +1,11 @@
import { zh } from './zh'
import { en } from './en'
export type Locale = 'zh' | 'en'
export type TranslationKeys = typeof zh
const messages: Record<Locale, TranslationKeys> = { zh, en }
export function getMessages(locale: Locale): TranslationKeys {
return messages[locale]
}
+139
View File
@@ -0,0 +1,139 @@
export const zh = {
app: {
name: 'BugPack',
version: 'v1.0.0',
},
nav: {
project: '项目',
settings: '设置',
shortcuts: '快捷键',
},
sidebar: {
searchPlaceholder: '搜索 Bug...',
newBug: '+ 新建 Bug',
filterAll: '全部',
filterPending: '待处理',
filterFixed: '已修复',
batchAction: '批量操作',
screenshots: '张截图',
timeAgo: '前',
},
status: {
pending: '待处理',
annotating: '标注中',
generated: '已生成',
fixed: '已修复',
closed: '已关闭',
},
priority: {
high: '高',
medium: '中',
low: '低',
},
editor: {
tools: {
drag: '拖拽',
select: '选择',
rect: '矩形框',
arrow: '箭头',
text: '文字',
number: '序号',
highlight: '高亮',
pen: '画笔',
mosaic: '马赛克',
undo: '撤销',
redo: '重做',
reset: '重置所有标注',
},
lineWidth: {
thin: '细',
medium: '中',
thick: '粗',
},
compare: '对比标注',
compareLeft: '当前效果',
compareRight: '期望效果',
fitWindow: '适应窗口',
emptyTitle: 'Ctrl+V 粘贴截图',
emptySubtitle: '或拖拽图片到此处',
emptyFormat: '支持 PNG / JPG / GIF',
copyImage: '复制图片',
copySuccess: '已复制到剪贴板',
copyFail: '复制失败',
},
evidence: {
title: '证据文件',
addFile: '添加文件',
annotated: '已标注',
notAnnotated: '未标注',
},
panel: {
bugInfo: 'Bug 信息',
title: '标题',
titlePlaceholder: '输入 Bug 标题...',
description: '描述',
descriptionPlaceholder: '详细描述 Bug 现象...',
priority: '优先级',
statusLabel: '状态',
envInfo: '环境信息',
pagePath: '页面路径',
pagePathPlaceholder: '例: /login',
device: '设备/分辨率',
devicePlaceholder: '例: iPhone 14 Pro 390x844',
browser: '浏览器',
browserPlaceholder: '例: Chrome 120.0',
relatedFiles: '关联文件',
addFilePath: '+ 添加文件路径',
filePathPlaceholder: '输入文件路径...',
smartSuggest: '智能推荐',
annotations: '标注说明',
generateBtn: '生成 AI 指令',
generateShortcut: 'Ctrl+Enter',
copyBtn: '复制',
exportBtn: '导出 MD',
},
preview: {
backToEdit: '返回编辑',
copy: '复制到剪贴板',
export: '导出 .md',
regenerate: '重新生成',
contents: '目录',
screenshots: '问题截图',
annotationsSection: '标注',
environment: '环境信息',
requirements: '修改要求',
relatedFilesSection: '相关文件',
aiInstructions: 'AI 指令',
},
statusBar: {
total: 'Bug 总数',
pending: '待处理',
fixed: '已修复',
mcp: 'MCP',
storage: '存储',
},
empty: {
title: '还没有 Bug,太棒了!',
subtitle: 'Ctrl+V 粘贴截图快速创建',
createFirst: '+ 创建第一个 Bug',
tip: '提示:从微信/钉钉复制 bug 截图,直接 Ctrl+V 粘贴即可',
},
settings: {
title: '设置',
projectConfig: '项目配置',
projectName: '项目名称',
rootDir: '代码根目录',
dataDir: '数据目录',
browse: '浏览',
integrations: '外部平台对接',
appearance: '外观',
shortcuts: '快捷键',
about: '关于',
cancel: '取消',
save: '保存',
},
}
+67
View File
@@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Theme variables - dark (default) */
:root {
--bg-primary: #0A0A0F;
--bg-sidebar: #0F0F17;
--bg-card: #13131A;
--bg-input: #1A1A26;
--bg-hover: #15151F;
--border: #1E1E2E;
--accent: #6366F1;
--accent-hover: #818CF8;
--text-primary: #E4E4E7;
--text-secondary: #A1A1AA;
--text-muted: #6B7280;
--scrollbar-track: #0A0A0F;
--scrollbar-thumb: #2E2E3E;
--scrollbar-thumb-hover: #3E3E4E;
}
/* Light theme */
:root.light {
--bg-primary: #F8F9FA;
--bg-sidebar: #FFFFFF;
--bg-card: #FFFFFF;
--bg-input: #F1F3F5;
--bg-hover: #E9ECEF;
--border: #DEE2E6;
--accent: #6366F1;
--accent-hover: #4F46E5;
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #868E96;
--scrollbar-track: #F8F9FA;
--scrollbar-thumb: #CED4DA;
--scrollbar-thumb-hover: #ADB5BD;
}
/* Global scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Drop zone highlight */
.drop-active {
border: 2px dashed var(--accent) !important;
background: color-mix(in srgb, var(--accent) 5%, transparent) !important;
}
/* Collapsible section transition */
.section-content {
overflow: hidden;
transition: max-height 0.2s ease-out;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
@@ -0,0 +1,406 @@
import { create } from 'zustand'
import { type Locale, getMessages, type TranslationKeys } from '../i18n'
import { api, type ApiBug, type ApiProject } from '../api'
export type BugStatus = 'pending' | 'annotating' | 'generated' | 'fixed' | 'closed'
export type Priority = 'high' | 'medium' | 'low'
export type FilterTab = 'all' | 'pending' | 'fixed'
// Filter bugs by tab
function filterByTab(bugs: Bug[], tab: FilterTab): Bug[] {
if (tab === 'pending') return bugs.filter(b => b.status === 'pending' || b.status === 'annotating')
if (tab === 'fixed') return bugs.filter(b => b.status === 'fixed')
return bugs
}
export interface Screenshot {
id: string
url: string
name: string
annotated: boolean
annotations: unknown[]
}
export interface Bug {
id: string
number: number
title: string
description: string
status: BugStatus
priority: Priority
screenshots: Screenshot[]
pagePath: string
device: string
browser: string
relatedFiles: string[]
createdAt: string
}
function toBug(raw: ApiBug): Bug {
return {
id: raw.id,
number: raw.number,
title: raw.title,
description: raw.description,
status: raw.status as BugStatus,
priority: raw.priority as Priority,
screenshots: raw.screenshots || [],
pagePath: raw.page_path ?? '',
device: raw.device ?? '',
browser: raw.browser ?? '',
relatedFiles: raw.relatedFiles || [],
createdAt: raw.created_at,
}
}
export type Theme = 'dark' | 'light'
export type ViewMode = 'edit' | 'preview'
interface AppState {
locale: Locale
t: TranslationKeys
setLocale: (locale: Locale) => void
bugs: Bug[]
selectedBugId: string | null
filterTab: FilterTab
searchQuery: string
loading: boolean
settings: Record<string, string>
projects: ApiProject[]
currentProjectId: string
theme: Theme
viewMode: ViewMode
settingsOpen: boolean
shortcutsOpen: boolean
currentProject: string
compareMode: boolean
compareLeft: number
compareRight: number
fetchBugs: () => Promise<void>
createBug: (title?: string) => Promise<Bug>
updateBug: (id: string, data: Record<string, unknown>) => Promise<void>
deleteBug: (id: string) => Promise<void>
pasteScreenshot: (bugId: string, dataUrl: string, name?: string) => Promise<void>
uploadScreenshot: (bugId: string, file: File, name?: string) => Promise<void>
deleteScreenshot: (bugId: string, ssId: string) => Promise<void>
renameScreenshot: (bugId: string, ssId: string, name: string) => Promise<void>
updateScreenshotAnnotated: (bugId: string, ssId: string) => Promise<void>
saveAnnotations: (bugId: string, ssId: string, annotations: unknown) => Promise<void>
reorderScreenshots: (bugId: string, order: string[]) => Promise<void>
batchUpdateStatus: (ids: string[], status: BugStatus) => Promise<void>
batchDeleteBugs: (ids: string[]) => Promise<void>
fetchSettings: () => Promise<void>
saveSettings: (data: Record<string, string>) => Promise<void>
fetchProjects: () => Promise<void>
createProject: (name: string) => Promise<ApiProject>
switchProject: (id: string) => Promise<void>
deleteProject: (id: string) => Promise<void>
selectBug: (id: string) => void
clearSelection: () => void
setFilterTab: (tab: FilterTab) => void
setSearchQuery: (query: string) => void
setViewMode: (mode: ViewMode) => void
setTheme: (theme: Theme) => void
setSettingsOpen: (open: boolean) => void
setShortcutsOpen: (open: boolean) => void
setCompareMode: (on: boolean) => void
setCompareLeft: (idx: number) => void
setCompareRight: (idx: number) => void
}
export const useStore = create<AppState>((set, get) => ({
locale: 'en',
t: getMessages('en'),
setLocale: (locale) => {
set({ locale, t: getMessages(locale) })
api.saveSettings({ locale }).catch(() => {})
},
bugs: [],
selectedBugId: null,
filterTab: 'all',
searchQuery: '',
loading: false,
settings: {},
projects: [],
currentProjectId: '',
theme: 'dark' as Theme,
viewMode: 'edit',
settingsOpen: false,
shortcutsOpen: false,
currentProject: '',
compareMode: false,
compareLeft: 0,
compareRight: 1,
fetchBugs: async () => {
set({ loading: true })
try {
const projectId = get().currentProjectId
const raw = await api.getBugs(projectId)
const bugs = raw.map(toBug)
const state = get()
const filtered = filterByTab(bugs, state.filterTab)
const selectedBugId = state.selectedBugId && filtered.find(b => b.id === state.selectedBugId)
? state.selectedBugId
: filtered[0]?.id ?? null
set({ bugs, selectedBugId, loading: false })
} catch (e) {
console.error('Failed to load bug list:', e)
set({ loading: false })
}
},
createBug: async (title) => {
const raw = await api.createBug({ title: title || '', project_id: get().currentProjectId })
const bug = toBug(raw)
set((s) => ({ bugs: [bug, ...s.bugs], selectedBugId: bug.id, viewMode: 'edit' }))
return bug
},
updateBug: async (id, data) => {
const raw = await api.updateBug(id, data)
const updated = toBug(raw)
set((s) => ({
bugs: s.bugs.map(b => b.id === id ? updated : b),
}))
},
deleteBug: async (id) => {
await api.deleteBug(id)
set((s) => {
const bugs = s.bugs.filter(b => b.id !== id)
// 根据当前 tab 过滤后选中第一个
const filtered = filterByTab(bugs, s.filterTab)
return {
bugs,
selectedBugId: s.selectedBugId === id ? (filtered[0]?.id ?? null) : s.selectedBugId,
}
})
},
pasteScreenshot: async (bugId, dataUrl, name) => {
await api.pasteScreenshot(bugId, dataUrl, name)
await get().fetchBugs()
},
uploadScreenshot: async (bugId, file, name) => {
await api.uploadScreenshot(bugId, file, name)
await get().fetchBugs()
},
deleteScreenshot: async (bugId, ssId) => {
await api.deleteScreenshot(bugId, ssId)
await get().fetchBugs()
},
renameScreenshot: async (bugId, ssId, name) => {
await api.renameScreenshot(bugId, ssId, name)
await get().fetchBugs()
},
updateScreenshotAnnotated: async (bugId, ssId) => {
await api.markScreenshotAnnotated(bugId, ssId)
set((s) => ({
bugs: s.bugs.map(b => b.id === bugId ? {
...b,
screenshots: b.screenshots.map(ss => ss.id === ssId ? { ...ss, annotated: true } : ss),
} : b),
}))
},
saveAnnotations: async (bugId, ssId, annotations) => {
await api.saveAnnotations(bugId, ssId, annotations)
set((s) => ({
bugs: s.bugs.map(b => b.id === bugId ? {
...b,
screenshots: b.screenshots.map(ss => ss.id === ssId ? { ...ss, annotations: annotations as unknown[] } : ss),
} : b),
}))
},
reorderScreenshots: async (bugId, order) => {
await api.reorderScreenshots(bugId, order)
set((s) => ({
bugs: s.bugs.map(b => {
if (b.id !== bugId) return b
const sorted = order.map(id => b.screenshots.find(ss => ss.id === id)!).filter(Boolean)
return { ...b, screenshots: sorted }
}),
}))
},
// Optimistic update, rollback on failure
batchUpdateStatus: async (ids, status) => {
const prevBugs = get().bugs
set((s) => {
const bugs = s.bugs.map(b => ids.includes(b.id) ? { ...b, status } : b)
const filtered = filterByTab(bugs, s.filterTab)
const stillVisible = filtered.some(b => b.id === s.selectedBugId)
return {
bugs,
selectedBugId: stillVisible ? s.selectedBugId : (filtered[0]?.id ?? null),
}
})
try {
await api.batchUpdateStatus(ids, status)
} catch (e) {
set({ bugs: prevBugs })
console.error('Batch status update failed:', e)
throw e
}
},
// Optimistic update, rollback on failure
batchDeleteBugs: async (ids) => {
const prevBugs = get().bugs
const prevSelected = get().selectedBugId
set((s) => {
const bugs = s.bugs.filter(b => !ids.includes(b.id))
const filtered = filterByTab(bugs, s.filterTab)
return {
bugs,
selectedBugId: ids.includes(s.selectedBugId || '') ? (filtered[0]?.id ?? null) : s.selectedBugId,
}
})
try {
await api.batchDeleteBugs(ids)
} catch (e) {
set({ bugs: prevBugs, selectedBugId: prevSelected })
console.error('Batch delete failed:', e)
throw e
}
},
fetchSettings: async () => {
try {
const settings = await api.getSettings()
set({ settings })
if (settings.theme === 'light' || settings.theme === 'dark') {
const theme = settings.theme as Theme
if (theme === 'light') {
document.documentElement.classList.add('light')
} else {
document.documentElement.classList.remove('light')
}
set({ theme })
}
if (settings.locale === 'zh' || settings.locale === 'en') {
const locale = settings.locale as Locale
set({ locale, t: getMessages(locale) })
}
if (settings.currentProjectId) {
set({ currentProjectId: settings.currentProjectId })
}
if (settings.filterTab === 'all' || settings.filterTab === 'pending' || settings.filterTab === 'fixed') {
set({ filterTab: settings.filterTab as FilterTab })
}
} catch (e) {
console.error('Failed to load settings:', e)
}
},
// Rollback on failure
saveSettings: async (data) => {
const prevSettings = get().settings
set({ settings: { ...prevSettings, ...data } })
try {
await api.saveSettings(data)
} catch (e) {
set({ settings: prevSettings })
console.error('Failed to save settings:', e)
throw e
}
},
fetchProjects: async () => {
try {
const projects = await api.getProjects()
set({ projects })
const savedId = get().currentProjectId
if (projects.length > 0) {
const targetId = savedId && projects.find(p => p.id === savedId) ? savedId : projects[0]!.id
await get().switchProject(targetId)
}
} catch (e) {
console.error('Failed to load project list:', e)
}
},
createProject: async (name) => {
const project = await api.createProject(name)
set((s) => ({ projects: [project, ...s.projects] }))
await get().switchProject(project.id)
return project
},
switchProject: async (id) => {
const project = get().projects.find(p => p.id === id)
set({
currentProjectId: id,
currentProject: project?.name || '',
bugs: [],
selectedBugId: null,
loading: true,
})
api.saveSettings({ currentProjectId: id }).catch(() => {})
await get().fetchBugs()
},
deleteProject: async (id) => {
await api.deleteProject(id)
const remaining = get().projects.filter(p => p.id !== id)
set({ projects: remaining })
if (get().currentProjectId === id) {
if (remaining.length > 0) {
await get().switchProject(remaining[0]!.id)
} else {
set({ currentProjectId: '', currentProject: '', bugs: [], selectedBugId: null })
}
}
},
selectBug: (id) => set({ selectedBugId: id, viewMode: 'edit' }),
clearSelection: () => set({ selectedBugId: null }),
setTheme: (theme) => {
if (theme === 'light') {
document.documentElement.classList.add('light')
} else {
document.documentElement.classList.remove('light')
}
set({ theme })
api.saveSettings({ theme }).catch(() => {})
},
setFilterTab: (tab) => {
const { bugs, selectedBugId } = get()
const filtered = filterByTab(bugs, tab)
const stillVisible = filtered.some(b => b.id === selectedBugId)
set({
filterTab: tab,
selectedBugId: stillVisible ? selectedBugId : (filtered[0]?.id ?? null),
})
api.saveSettings({ filterTab: tab }).catch(() => {})
},
setSearchQuery: (query) => set({ searchQuery: query }),
setViewMode: (mode) => set({ viewMode: mode }),
setSettingsOpen: (open) => set({ settingsOpen: open }),
setShortcutsOpen: (open) => set({ shortcutsOpen: open }),
setCompareMode: (on) => set({ compareMode: on }),
setCompareLeft: (idx) => set({ compareLeft: idx }),
setCompareRight: (idx) => set({ compareRight: idx }),
}))
@@ -0,0 +1,110 @@
import type { Bug } from '../stores'
// Compare mode info
export interface CompareInfo {
enabled: boolean
leftIndex: number
rightIndex: number
}
// Generate structured Markdown instruction
export function generateInstruction(
bug: Bug,
locale: 'zh' | 'en' = 'en',
compare?: CompareInfo,
projectName?: string,
): string {
const isZh = locale === 'zh'
const lines: string[] = []
// Title (with project name)
const projectPrefix = projectName ? `[${projectName}] ` : ''
lines.push(`# ${projectPrefix}Bug #${String(bug.number).padStart(3, '0')}: ${bug.title || (isZh ? '未命名 Bug' : 'Untitled Bug')}`)
lines.push('')
// Bug description
if (bug.description) {
const hasHistory = bug.description.includes('## History')
lines.push(`## ${isZh ? '问题描述' : 'Description'}`)
lines.push(bug.description)
lines.push('')
if (hasHistory) {
lines.push(isZh
? '**注意:** 历史记录按时间排序,最新的评论反映当前需要解决的问题,较早的评论对应的问题可能已修复。请优先关注最新评论。'
: '**Note:** History is sorted chronologically. The latest comments reflect the current issue to fix — earlier comments may already be resolved. Focus on the most recent entries.')
} else {
lines.push(isZh ? '**期望行为:** 请根据截图和描述修复此问题。' : '**Expected behavior:** Please fix this issue based on the screenshots and description.')
}
lines.push('')
}
// Screenshots
if (bug.screenshots.length > 0) {
lines.push(`## ${isZh ? '问题截图' : 'Issue Screenshots'}`)
lines.push('')
// Compare mode: annotate current vs expected state
if (compare?.enabled && bug.screenshots.length >= 2) {
const safeLeft = Math.max(0, Math.min(compare.leftIndex, bug.screenshots.length - 1))
const safeRight = Math.max(0, Math.min(compare.rightIndex, bug.screenshots.length - 1))
const leftSS = bug.screenshots[safeLeft]
const rightSS = bug.screenshots[safeRight]
if (leftSS) {
const leftLabel = leftSS.name || `${isZh ? '截图' : 'Screenshot'} ${compare.leftIndex + 1}`
lines.push(`### ${isZh ? '当前效果' : 'Current State'}${leftLabel}`)
lines.push(`![${leftLabel}](${leftSS.url})`)
lines.push('')
}
if (rightSS) {
const rightLabel = rightSS.name || `${isZh ? '截图' : 'Screenshot'} ${compare.rightIndex + 1}`
lines.push(`### ${isZh ? '期望效果' : 'Expected Result'}${rightLabel}`)
lines.push(`![${rightLabel}](${rightSS.url})`)
lines.push('')
}
// Remaining screenshots as supplementary
bug.screenshots.forEach((ss, i) => {
if (i === safeLeft || i === safeRight) return
const label = ss.name || `${isZh ? '截图' : 'Screenshot'} ${i + 1}`
lines.push(`### ${label}`)
lines.push(`![${label}](${ss.url})`)
lines.push('')
})
} else {
bug.screenshots.forEach((ss, i) => {
const label = ss.name || `${isZh ? '截图' : 'Screenshot'} ${i + 1}`
lines.push(`### ${label}`)
lines.push(`![${label}](${ss.url})`)
lines.push(isZh ? `_${label} - 请仔细查看标注区域_` : `_${label} - Please examine the annotated areas_`)
lines.push('')
})
}
}
// Environment info
if (bug.pagePath || bug.device || bug.browser) {
lines.push(`## ${isZh ? '环境信息' : 'Environment Info'}`)
if (bug.pagePath) lines.push(`- ${isZh ? '页面路径' : 'Page'}: ${bug.pagePath}`)
if (bug.device) lines.push(`- ${isZh ? '设备' : 'Device'}: ${bug.device}`)
if (bug.browser) lines.push(`- ${isZh ? '浏览器' : 'Browser'}: ${bug.browser}`)
lines.push('')
}
// Related files
if (bug.relatedFiles.length > 0) {
lines.push(`## ${isZh ? '相关文件' : 'Related Files'}`)
bug.relatedFiles.forEach((f) => lines.push(`- ${f}`))
lines.push('')
}
// Priority
const priorityMap = {
high: isZh ? '高' : 'High',
medium: isZh ? '中' : 'Medium',
low: isZh ? '低' : 'Low',
}
lines.push(`## ${isZh ? '优先级' : 'Priority'}`)
lines.push(priorityMap[bug.priority])
lines.push('')
return lines.join('\n')
}
+365
View File
@@ -0,0 +1,365 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import os from 'os'
// Database path: ~/.bugpack/data/ (shared with server)
const DATA_DIR = path.join(os.homedir(), '.bugpack', 'data')
const DB_PATH = path.join(DATA_DIR, 'bugpack.db')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
// Map file extension to MIME type
const MIME_MAP: Record<string, string> = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
pdf: 'application/pdf',
doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
txt: 'text/plain', csv: 'text/csv', html: 'text/html', md: 'text/markdown',
json: 'application/json', xml: 'application/xml', zip: 'application/zip',
}
function extToMime(ext: string): string {
return MIME_MAP[ext] || 'application/octet-stream'
}
function getDb() {
if (!fs.existsSync(DB_PATH)) {
throw new Error(`Database not found: ${DB_PATH}, please start BugPack Server first`)
}
return new Database(DB_PATH, { readonly: true })
}
const server = new McpServer({
name: 'bugpack',
version: '1.0.0',
})
// Find project ID by project name
function findProjectId(db: Database.Database, projectName?: string): string | null {
if (!projectName) return null
const project: any = db.prepare('SELECT id FROM projects WHERE name = ?').get(projectName)
return project?.id || null
}
// Find bug by bug_number + project (number is unique within project)
function findBug(db: Database.Database, bugNumber: number, projectName?: string): any {
if (projectName) {
const projectId = findProjectId(db, projectName)
if (projectId) {
return db.prepare('SELECT * FROM bugs WHERE number = ? AND project_id = ?').get(bugNumber, projectId)
}
return null
}
// No project name: return first match (backward compatible)
return db.prepare('SELECT * FROM bugs WHERE number = ?').get(bugNumber)
}
// Get project name
function getProjectName(db: Database.Database, projectId: string): string {
const project: any = db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId)
return project?.name || ''
}
// ---- list_bugs: list all bugs to fix ----
server.tool(
'list_bugs',
'List all bugs grouped by project. Filter by project name or status',
{
status: z.string().optional().describe('Filter by status: pending/annotating/generated/fixed/closed'),
project: z.string().optional().describe('Filter by project name'),
},
async ({ status, project }) => {
const db = getDb()
try {
const conditions: string[] = []
const params: any[] = []
const validStatuses = ['pending', 'annotating', 'generated', 'fixed', 'closed']
if (status) {
if (!validStatuses.includes(status)) {
return { content: [{ type: 'text', text: `Invalid status: ${status}` }] }
}
conditions.push('b.status = ?')
params.push(status)
}
if (project) {
const projectId = findProjectId(db, project)
if (!projectId) {
return { content: [{ type: 'text', text: `Project "${project}" not found` }] }
}
conditions.push('b.project_id = ?')
params.push(projectId)
}
const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : ''
const query = `
SELECT b.id, b.number, b.title, b.status, b.priority, b.project_id, b.created_at,
(SELECT COUNT(*) FROM screenshots WHERE bug_id = b.id) as screenshot_count
FROM bugs b${where}
ORDER BY b.project_id, b.number DESC
`
const bugs = db.prepare(query).all(...params) as any[]
if (bugs.length === 0) {
return { content: [{ type: 'text', text: 'No bugs found' }] }
}
// Group by project
const groups: Record<string, { name: string; bugs: any[] }> = {}
for (const b of bugs) {
if (!groups[b.project_id]) {
groups[b.project_id] = { name: getProjectName(db, b.project_id), bugs: [] }
}
groups[b.project_id]!.bugs.push(b)
}
const lines: string[] = []
for (const group of Object.values(groups)) {
lines.push(`## ${group.name || 'Uncategorized'}`)
for (const b of group.bugs) {
lines.push(` #${String(b.number).padStart(3, '0')} [${b.status}] [${b.priority}] ${b.title} (${b.screenshot_count} screenshots)`)
}
lines.push('')
}
return {
content: [{ type: 'text', text: lines.join('\n').trim() }],
}
} finally {
db.close()
}
}
)
// ---- get_bug_context: get full bug context ----
server.tool(
'get_bug_context',
'Get full bug context (annotated screenshots + fix instructions) for AI repair',
{
bug_id: z.string().optional().describe('Bug ID'),
bug_number: z.coerce.number().optional().describe('Bug number within project, e.g. 1, 2, 3'),
project: z.string().optional().describe('Project name to locate bug number within'),
},
async ({ bug_id, bug_number, project }) => {
const db = getDb()
try {
let bug: any
if (bug_id) {
bug = db.prepare('SELECT * FROM bugs WHERE id = ?').get(bug_id)
} else if (bug_number) {
bug = findBug(db, bug_number, project)
}
if (!bug) {
return { content: [{ type: 'text', text: 'Bug not found' }] }
}
const screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id) as any[]
const projectName = getProjectName(db, bug.project_id)
let relatedFiles: string[] = []
try { relatedFiles = JSON.parse(bug.related_files || '[]') } catch { /* ignore */ }
// Generate structured Markdown
const lines: string[] = []
const projectPrefix = projectName ? `[${projectName}] ` : ''
lines.push(`# ${projectPrefix}Bug #${String(bug.number).padStart(3, '0')}: ${bug.title}`)
lines.push('')
if (bug.description) {
lines.push('## Description')
lines.push(bug.description)
lines.push('')
if (bug.description.includes('## History')) {
lines.push('**Note:** History is sorted chronologically. The latest comments reflect the current issue to fix — earlier comments may already be resolved. Focus on the most recent entries.')
lines.push('')
}
}
if (screenshots.length > 0) {
lines.push('## Screenshots')
lines.push('')
}
if (bug.page_path || bug.device || bug.browser) {
lines.push('## Environment')
if (bug.page_path) lines.push(`- Page: ${bug.page_path}`)
if (bug.device) lines.push(`- Device: ${bug.device}`)
if (bug.browser) lines.push(`- Browser: ${bug.browser}`)
lines.push('')
}
if (relatedFiles.length > 0) {
lines.push('## Related Files')
relatedFiles.forEach((f: string) => lines.push(`- ${f}`))
lines.push('')
}
lines.push(`## Priority`)
lines.push(bug.priority)
// Build response content (text + images)
const content: any[] = [{ type: 'text', text: lines.join('\n') }]
// Attach evidence files
const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']
const MAX_BASE64_SIZE = 5 * 1024 * 1024 // 5MB, larger images use path
for (const ss of screenshots) {
const annotatedPath = ss.annotated_filename ? path.join(UPLOADS_DIR, ss.annotated_filename) : ''
const originalPath = path.join(UPLOADS_DIR, ss.filename)
const filePath = (annotatedPath && fs.existsSync(annotatedPath)) ? annotatedPath : originalPath
if (!fs.existsSync(filePath)) continue
const ext = path.extname(filePath).slice(1).toLowerCase()
const fileSize = fs.statSync(filePath).size
if (IMAGE_EXTS.includes(ext) && fileSize <= MAX_BASE64_SIZE) {
const data = fs.readFileSync(filePath)
const mimeType = extToMime(ext)
content.push({ type: 'image', data: data.toString('base64'), mimeType })
} else {
content.push({ type: 'text', text: `[${ss.name}] ${filePath}` })
}
}
return { content }
} finally {
db.close()
}
}
)
// ---- get_bug_screenshot: get bug screenshot ----
server.tool(
'get_bug_screenshot',
'Get annotated screenshot of a bug',
{
bug_number: z.coerce.number().describe('Bug number within project'),
screenshot_index: z.coerce.number().optional().describe('Screenshot index (0-based), defaults to first'),
project: z.string().optional().describe('Project name'),
},
async ({ bug_number, screenshot_index = 0, project }) => {
const db = getDb()
let bug: any
let screenshots: any[]
try {
bug = findBug(db, bug_number, project)
if (!bug) {
return { content: [{ type: 'text', text: 'Bug not found' }] }
}
screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id) as any[]
} finally {
db.close()
}
const ss = screenshots[screenshot_index]
if (!ss) {
return { content: [{ type: 'text', text: `Screenshot #${screenshot_index} not found` }] }
}
// Prefer annotated render image
const annotatedPath = ss.annotated_filename ? path.join(UPLOADS_DIR, ss.annotated_filename) : ''
const originalPath = path.join(UPLOADS_DIR, ss.filename)
const filePath = (annotatedPath && fs.existsSync(annotatedPath)) ? annotatedPath : originalPath
if (!fs.existsSync(filePath)) {
return { content: [{ type: 'text', text: 'File not found' }] }
}
const ext = path.extname(filePath).slice(1).toLowerCase()
const label = `Bug #${String(bug.number).padStart(3, '0')} - ${ss.name}${ss.annotated_filename ? ' (annotated)' : ''}`
const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']
const fileSize = fs.statSync(filePath).size
if (IMAGE_EXTS.includes(ext) && fileSize <= 5 * 1024 * 1024) {
const data = fs.readFileSync(filePath)
const mimeType = extToMime(ext)
return {
content: [
{ type: 'text', text: label },
{ type: 'image', data: data.toString('base64'), mimeType },
],
}
} else {
return {
content: [
{ type: 'text', text: `${label}\n${filePath}` },
],
}
}
}
)
// ---- mark_bug_status: update status ----
server.tool(
'mark_bug_status',
'Update bug status (pending/fixed/closed etc.)',
{
bug_number: z.coerce.number().describe('Bug number within project'),
status: z.enum(['pending', 'annotating', 'generated', 'fixed', 'closed']).describe('New status'),
project: z.string().optional().describe('Project name'),
},
async ({ bug_number, status, project }) => {
const dbPath = path.join(DATA_DIR, 'bugpack.db')
const db = new Database(dbPath)
try {
const bug: any = findBug(db, bug_number, project)
if (!bug) {
return { content: [{ type: 'text', text: 'Bug not found' }] }
}
db.prepare("UPDATE bugs SET status = ?, updated_at = datetime('now') WHERE id = ?").run(status, bug.id)
const projectName = getProjectName(db, bug.project_id)
const prefix = projectName ? `[${projectName}] ` : ''
return {
content: [{ type: 'text', text: `${prefix}Bug #${String(bug_number).padStart(3, '0')} status updated to: ${status}` }],
}
} finally {
db.close()
}
}
)
// ---- add_fix_note: add fix notes ----
server.tool(
'add_fix_note',
'Add fix notes to bug description after AI repair',
{
bug_number: z.coerce.number().describe('Bug number within project'),
note: z.string().describe('Fix notes'),
project: z.string().optional().describe('Project name'),
},
async ({ bug_number, note, project }) => {
const dbPath = path.join(DATA_DIR, 'bugpack.db')
const db = new Database(dbPath)
try {
const bug: any = findBug(db, bug_number, project)
if (!bug) {
return { content: [{ type: 'text', text: 'Bug not found' }] }
}
const newDesc = bug.description
? `${bug.description}\n\n---\n## Fix Notes\n${note}`
: `## Fix Notes\n${note}`
db.prepare("UPDATE bugs SET description = ?, updated_at = datetime('now') WHERE id = ?").run(newDesc, bug.id)
const projectName = getProjectName(db, bug.project_id)
const prefix = projectName ? `[${projectName}] ` : ''
return {
content: [{ type: 'text', text: `${prefix}Fix notes added to Bug #${String(bug_number).padStart(3, '0')}` }],
}
} finally {
db.close()
}
}
)
// Start
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
}
main().catch((err) => { console.error('MCP server failed to start:', err); process.exit(1) })
+108
View File
@@ -0,0 +1,108 @@
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import os from 'os'
const DATA_DIR = path.join(os.homedir(), '.bugpack', 'data')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.mkdirSync(UPLOADS_DIR, { recursive: true })
const db = new Database(path.join(DATA_DIR, 'bugpack.db'))
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS bugs (
id TEXT PRIMARY KEY,
number INTEGER UNIQUE NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'medium',
page_path TEXT NOT NULL DEFAULT '',
device TEXT NOT NULL DEFAULT '',
browser TEXT NOT NULL DEFAULT '',
related_files TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS screenshots (
id TEXT PRIMARY KEY,
bug_id TEXT NOT NULL REFERENCES bugs(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
annotated INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
annotations TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_screenshots_bug_id ON screenshots(bug_id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
// Migration: add project_id
try {
db.exec(`ALTER TABLE bugs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'`)
} catch {
// Column already exists, ignore
}
// Migration: make number unique per project
try {
const hasOldUnique = db.prepare(
`SELECT sql FROM sqlite_master WHERE type='table' AND name='bugs'`
).get() as any
if (hasOldUnique?.sql?.includes('number INTEGER UNIQUE')) {
// Disable FK to prevent cascade on DROP
db.pragma('foreign_keys = OFF')
db.exec(`
CREATE TABLE IF NOT EXISTS bugs_new (
id TEXT PRIMARY KEY,
number INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'medium',
page_path TEXT NOT NULL DEFAULT '',
device TEXT NOT NULL DEFAULT '',
browser TEXT NOT NULL DEFAULT '',
related_files TEXT NOT NULL DEFAULT '[]',
project_id TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO bugs_new SELECT id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at FROM bugs;
DROP TABLE bugs;
ALTER TABLE bugs_new RENAME TO bugs;
`)
db.pragma('foreign_keys = ON')
}
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bugs_project_number ON bugs(project_id, number)`)
} catch {
// Already migrated, ignore
}
// Migration: add annotated_filename
try {
db.exec(`ALTER TABLE screenshots ADD COLUMN annotated_filename TEXT NOT NULL DEFAULT ''`)
} catch {
// Column already exists, ignore
}
export { db, DATA_DIR, UPLOADS_DIR }
+63
View File
@@ -0,0 +1,63 @@
import express from 'express'
import cors from 'cors'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
import { bugsRouter } from './routes/bugs.js'
import { settingsRouter } from './routes/settings.js'
import { projectsRouter } from './routes/projects.js'
import { zentaoRouter } from './routes/zentao.js'
import { jiraRouter } from './routes/jira.js'
import { linearRouter } from './routes/linear.js'
import { tapdRouter } from './routes/tapd.js'
import { UPLOADS_DIR } from './db.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
const PORT = parseInt(process.env.PORT || '3457', 10)
app.use(cors())
app.use(express.json({ limit: '50mb' }))
// Security headers
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
next()
})
// Static files: screenshots
app.use('/uploads', express.static(UPLOADS_DIR, { dotfiles: 'deny' }))
// API routes
app.use('/api/bugs', bugsRouter)
app.use('/api/settings', settingsRouter)
app.use('/api/projects', projectsRouter)
app.use('/api/zentao', zentaoRouter)
app.use('/api/jira', jiraRouter)
app.use('/api/linear', linearRouter)
app.use('/api/tapd', tapdRouter)
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', version: '1.0.0' })
})
// Production: serve frontend static files
// Try dist/client first (built files), fallback to src/client (dev)
const clientDir = path.resolve(__dirname, '../../dist/client')
const devClientDir = path.resolve(__dirname, '../client')
const staticDir = fs.existsSync(clientDir) ? clientDir : devClientDir
if (fs.existsSync(staticDir)) {
app.use(express.static(staticDir))
app.get('*', (req, res) => {
if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads')) {
res.sendFile(path.join(staticDir, 'index.html'))
}
})
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`BugPack server running at http://localhost:${PORT}`)
})
+368
View File
@@ -0,0 +1,368 @@
import { Router } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import { v4 as uuid } from 'uuid'
import { db, UPLOADS_DIR } from '../db.js'
export const bugsRouter = Router()
// Safe JSON parse
function safeJsonParse(str: string | null | undefined, fallback: any = []) {
if (!str) return fallback
try { return JSON.parse(str) } catch { return fallback }
}
// Get the project upload directory for a bug
function getProjectUploadsDir(bugId: string): string {
const bug: any = db.prepare('SELECT project_id FROM bugs WHERE id = ?').get(bugId)
const projectId = bug?.project_id || 'default'
const project: any = db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId)
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
const dir = path.join(UPLOADS_DIR, projectName)
fs.mkdirSync(dir, { recursive: true })
return dir
}
// File upload config
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = getProjectUploadsDir(req.params.id as string)
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.png'
cb(null, `${uuid()}${ext}`)
},
})
const ALLOWED_MIMES = [
// Images
'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/svg+xml',
// Documents
'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text
'text/plain', 'text/csv', 'text/html', 'text/markdown',
// Data
'application/json', 'application/xml', 'text/xml',
// Archives (for logs etc.)
'application/zip',
]
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
cb(null, ALLOWED_MIMES.includes(file.mimetype))
},
})
// ---- List all bugs ----
bugsRouter.get('/', (req, res) => {
const projectId = (req.query.project_id as string) || undefined
const sql = projectId
? `SELECT b.*, (SELECT COUNT(*) FROM screenshots WHERE bug_id = b.id) as screenshot_count FROM bugs b WHERE b.project_id = ? ORDER BY b.created_at DESC`
: `SELECT b.*, (SELECT COUNT(*) FROM screenshots WHERE bug_id = b.id) as screenshot_count FROM bugs b ORDER BY b.created_at DESC`
const bugs = projectId ? db.prepare(sql).all(projectId) : db.prepare(sql).all()
// Get screenshots for each bug
const getScreenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order')
const result = bugs.map((bug: any) => ({
...bug,
relatedFiles: safeJsonParse(bug.related_files, []),
screenshots: getScreenshots.all(bug.id).map((s: any) => ({
id: s.id,
url: `/uploads/${s.filename}`,
name: s.name,
annotated: !!s.annotated,
annotations: safeJsonParse(s.annotations, []),
})),
}))
res.json(result)
})
// ---- Get single bug ----
bugsRouter.get('/:id', (req, res) => {
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
if (!bug) return res.status(404).json({ error: 'Bug not found' })
const screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id)
res.json({
...bug,
relatedFiles: safeJsonParse(bug.related_files, []),
screenshots: screenshots.map((s: any) => ({
id: s.id,
url: `/uploads/${s.filename}`,
name: s.name,
annotated: !!s.annotated,
annotations: safeJsonParse(s.annotations, []),
})),
})
})
// ---- Create bug ----
bugsRouter.post('/', (req, res) => {
const id = uuid()
const { title = '', description = '', priority = 'medium', pagePath = '', device = '', browser = '', project_id = 'default' } = req.body
const maxNum: any = db.prepare('SELECT MAX(number) as n FROM bugs WHERE project_id = ?').get(project_id)
const number = (maxNum?.n || 0) + 1
db.prepare(`
INSERT INTO bugs (id, number, title, description, priority, page_path, device, browser, project_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, number, title, description, priority, pagePath, device, browser, project_id)
const bug = db.prepare('SELECT * FROM bugs WHERE id = ?').get(id) as Record<string, unknown>
res.status(201).json({ ...bug, relatedFiles: [], screenshots: [] })
})
// ---- Batch update status ----
bugsRouter.patch('/batch/status', (req, res) => {
const { ids, status } = req.body as { ids: string[]; status: string }
if (!Array.isArray(ids) || !status) return res.status(400).json({ error: 'Invalid parameters' })
const stmt = db.prepare("UPDATE bugs SET status = ?, updated_at = datetime('now') WHERE id = ?")
const updateAll = db.transaction(() => {
for (const id of ids) stmt.run(status, id)
})
updateAll()
res.json({ ok: true })
})
// ---- Batch delete ----
bugsRouter.post('/batch/delete', (req, res) => {
const { ids } = req.body as { ids: string[] }
if (!Array.isArray(ids)) return res.status(400).json({ error: 'Invalid parameters' })
const deleteAll = db.transaction(() => {
for (const id of ids) {
const screenshots: any[] = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE bug_id = ?').all(id)
for (const ss of screenshots) {
const filePath = path.join(UPLOADS_DIR, ss.filename)
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
if (ss.annotated_filename) {
const annotatedPath = path.join(UPLOADS_DIR, ss.annotated_filename)
try { if (fs.existsSync(annotatedPath)) fs.unlinkSync(annotatedPath) } catch { /* ignore file deletion error */ }
}
}
db.prepare('DELETE FROM screenshots WHERE bug_id = ?').run(id)
db.prepare('DELETE FROM bugs WHERE id = ?').run(id)
}
})
deleteAll()
res.json({ ok: true })
})
// ---- Update bug ----
bugsRouter.patch('/:id', (req, res) => {
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
if (!bug) return res.status(404).json({ error: 'Bug not found' })
const { title, description, status, priority, pagePath, device, browser, relatedFiles } = req.body
const updates: string[] = []
const values: any[] = []
if (title !== undefined) { updates.push('title = ?'); values.push(title) }
if (description !== undefined) { updates.push('description = ?'); values.push(description) }
if (status !== undefined) { updates.push('status = ?'); values.push(status) }
if (priority !== undefined) { updates.push('priority = ?'); values.push(priority) }
if (pagePath !== undefined) { updates.push('page_path = ?'); values.push(pagePath) }
if (device !== undefined) { updates.push('device = ?'); values.push(device) }
if (browser !== undefined) { updates.push('browser = ?'); values.push(browser) }
if (relatedFiles !== undefined) { updates.push('related_files = ?'); values.push(JSON.stringify(relatedFiles)) }
if (updates.length > 0) {
updates.push("updated_at = datetime('now')")
values.push(req.params.id)
db.prepare(`UPDATE bugs SET ${updates.join(', ')} WHERE id = ?`).run(...values)
}
// Return full bug data (with screenshots and relatedFiles)
const updated: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
const screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(req.params.id)
res.json({
...updated,
relatedFiles: safeJsonParse(updated.related_files, []),
screenshots: screenshots.map((s: any) => ({
id: s.id,
url: `/uploads/${s.filename}`,
name: s.name,
annotated: !!s.annotated,
annotations: safeJsonParse(s.annotations, []),
})),
})
})
// ---- Delete bug ----
bugsRouter.delete('/:id', (req, res) => {
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
if (!bug) return res.status(404).json({ error: 'Bug not found' })
// Delete associated screenshot files from disk
const screenshots: any[] = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE bug_id = ?').all(req.params.id)
for (const ss of screenshots) {
const filePath = path.join(UPLOADS_DIR, ss.filename)
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
if (ss.annotated_filename) {
const annotatedPath = path.join(UPLOADS_DIR, ss.annotated_filename)
try { if (fs.existsSync(annotatedPath)) fs.unlinkSync(annotatedPath) } catch { /* ignore file deletion error */ }
}
}
db.prepare('DELETE FROM screenshots WHERE bug_id = ?').run(req.params.id)
db.prepare('DELETE FROM bugs WHERE id = ?').run(req.params.id)
res.json({ ok: true })
})
// ---- Upload screenshot ----
bugsRouter.post('/:id/screenshots', upload.single('file'), (req, res) => {
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
if (!bug) return res.status(404).json({ error: 'Bug not found' })
if (!req.file) return res.status(400).json({ error: 'No file selected' })
const id = uuid()
const maxOrder: any = db.prepare('SELECT MAX(sort_order) as n FROM screenshots WHERE bug_id = ?').get(req.params.id)
const sortOrder = (maxOrder?.n || 0) + 1
const name = req.body.name || req.file.originalname
// Calculate path relative to UPLOADS_DIR
const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/')
db.prepare(`
INSERT INTO screenshots (id, bug_id, filename, original_name, name, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, req.params.id, relPath, req.file.originalname, name, sortOrder)
res.status(201).json({
id,
url: `/uploads/${relPath}`,
name,
annotated: false,
annotations: [],
})
})
// ---- Paste screenshot (Base64) ----
bugsRouter.post('/:id/screenshots/paste', (req, res) => {
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
if (!bug) return res.status(404).json({ error: 'Bug not found' })
const { dataUrl, name = 'Pasted screenshot' } = req.body
if (!dataUrl) return res.status(400).json({ error: 'Missing image data' })
// Parse base64
const matches = dataUrl.match(/^data:image\/([\w+]+);base64,(.+)$/)
if (!matches) return res.status(400).json({ error: 'Invalid image format' })
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]
const buffer = Buffer.from(matches[2], 'base64')
const filename = `${uuid()}.${ext}`
const projectDir = getProjectUploadsDir(req.params.id)
const fullPath = path.join(projectDir, filename)
const relPath = path.relative(UPLOADS_DIR, fullPath).replace(/\\/g, '/')
if (relPath.includes('..')) return res.status(400).json({ error: 'Invalid file path' })
fs.writeFileSync(fullPath, buffer)
const id = uuid()
const maxOrder: any = db.prepare('SELECT MAX(sort_order) as n FROM screenshots WHERE bug_id = ?').get(req.params.id)
const sortOrder = (maxOrder?.n || 0) + 1
db.prepare(`
INSERT INTO screenshots (id, bug_id, filename, original_name, name, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, req.params.id, relPath, 'paste.png', name, sortOrder)
res.status(201).json({
id,
url: `/uploads/${relPath}`,
name,
annotated: false,
annotations: [],
})
})
// ---- Update screenshot ----
bugsRouter.patch('/:bugId/screenshots/:ssId', (req, res) => {
const { name, annotated, annotations } = req.body
if (name !== undefined) {
db.prepare('UPDATE screenshots SET name = ? WHERE id = ? AND bug_id = ?').run(name, req.params.ssId, req.params.bugId)
}
if (annotated !== undefined) {
db.prepare('UPDATE screenshots SET annotated = ? WHERE id = ? AND bug_id = ?').run(annotated ? 1 : 0, req.params.ssId, req.params.bugId)
}
if (annotations !== undefined) {
db.prepare('UPDATE screenshots SET annotations = ? WHERE id = ? AND bug_id = ?').run(JSON.stringify(annotations), req.params.ssId, req.params.bugId)
}
res.json({ ok: true })
})
// ---- Save annotated render image ----
bugsRouter.post('/:bugId/screenshots/:ssId/annotated-image', (req, res) => {
const { dataUrl } = req.body as { dataUrl: string }
if (!dataUrl || !dataUrl.startsWith('data:image/')) {
return res.status(400).json({ error: 'Invalid image data' })
}
const ss: any = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE id = ? AND bug_id = ?')
.get(req.params.ssId, req.params.bugId)
if (!ss) return res.status(404).json({ error: 'Screenshot not found' })
// Parse base64
const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/)
if (!matches || !matches[1] || !matches[2]) return res.status(400).json({ error: 'Cannot parse image data' })
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]
const buffer = Buffer.from(matches[2], 'base64')
// Save to same directory as original image
const dir = path.dirname(path.join(UPLOADS_DIR, ss.filename))
const baseName = path.basename(ss.filename, path.extname(ss.filename))
const annotatedFilename = path.dirname(ss.filename) + '/' + baseName + '_annotated.' + ext
const annotatedPath = path.join(UPLOADS_DIR, annotatedFilename)
// Path security check
const relCheck = path.relative(UPLOADS_DIR, annotatedPath)
if (relCheck.includes('..')) return res.status(400).json({ error: 'Invalid file path' })
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(annotatedPath, buffer)
db.prepare('UPDATE screenshots SET annotated_filename = ? WHERE id = ? AND bug_id = ?')
.run(annotatedFilename, req.params.ssId, req.params.bugId)
res.json({ ok: true, annotatedFilename })
})
// ---- Reorder screenshots ----
bugsRouter.put('/:bugId/screenshots/reorder', (req, res) => {
const { order } = req.body as { order: string[] }
if (!Array.isArray(order)) return res.status(400).json({ error: 'order array required' })
const stmt = db.prepare('UPDATE screenshots SET sort_order = ? WHERE id = ? AND bug_id = ?')
const updateAll = db.transaction(() => {
for (let i = 0; i < order.length; i++) {
stmt.run(i, order[i], req.params.bugId)
}
})
updateAll()
res.json({ ok: true })
})
// ---- Delete screenshot ----
bugsRouter.delete('/:bugId/screenshots/:ssId', (req, res) => {
const ss: any = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE id = ? AND bug_id = ?').get(req.params.ssId, req.params.bugId)
if (ss) {
const filePath = path.join(UPLOADS_DIR, ss.filename)
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
if (ss.annotated_filename) {
const annotatedPath = path.join(UPLOADS_DIR, ss.annotated_filename)
try { if (fs.existsSync(annotatedPath)) fs.unlinkSync(annotatedPath) } catch { /* ignore file deletion error */ }
}
}
db.prepare('DELETE FROM screenshots WHERE id = ? AND bug_id = ?').run(req.params.ssId, req.params.bugId)
res.json({ ok: true })
})
+229
View File
@@ -0,0 +1,229 @@
import { Router } from 'express'
import crypto from 'crypto'
import { db } from '../db.js'
export const jiraRouter = Router()
// Get Jira config
function getJiraConfig() {
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('jira%') as { key: string; value: string }[]
const config: Record<string, string> = {}
for (const row of rows) config[row.key] = row.value
return {
url: (config.jiraUrl || '').replace(/\/+$/, ''),
email: config.jiraEmail || '',
token: config.jiraToken || '',
projectKey: config.jiraProjectKey || '',
}
}
// Build Basic Auth header (Jira Cloud: email + API Token)
function makeHeaders(email: string, token: string, extra?: Record<string, string>): Record<string, string> {
return {
Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
'Content-Type': 'application/json',
...extra,
}
}
// Request timeout (15s)
const TIMEOUT = 15000
// Jira API request (v3)
async function jiraFetch(baseUrl: string, email: string, token: string, path: string) {
const headers = makeHeaders(email, token)
const res = await fetch(`${baseUrl}/rest/api/3${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Jira request failed: HTTP ${res.status} ${text.slice(0, 200)}`)
}
return res.json()
}
// Test connection
jiraRouter.post('/test', async (req, res) => {
try {
const { url, email, token } = req.body
const baseUrl = (url || '').replace(/\/+$/, '')
if (!baseUrl || !email || !token) {
return res.json({ ok: false, error: 'Please fill in all Jira configuration fields' })
}
// Test by fetching current user
const data = await jiraFetch(baseUrl, email, token, '/myself') as any
res.json({ ok: true, user: data.displayName || data.emailAddress })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get project list
jiraRouter.get('/projects', async (_req, res) => {
try {
const config = getJiraConfig()
if (!config.url) return res.json({ ok: false, error: 'Jira URL not configured' })
const data = await jiraFetch(config.url, config.email, config.token, '/project') as any[]
res.json({
ok: true,
projects: data.map((p: any) => ({ id: p.id, key: p.key, name: p.name })),
})
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get bug list (JQL: bugs assigned to current user)
jiraRouter.get('/bugs', async (_req, res) => {
try {
const config = getJiraConfig()
if (!config.url) return res.json({ ok: false, error: 'Jira URL not configured' })
if (!config.projectKey) return res.json({ ok: false, error: 'Please select a project first' })
const jql = `project = "${config.projectKey}" AND assignee = currentUser() AND issuetype = Bug AND statusCategory != Done ORDER BY created DESC`
const data = await jiraFetch(
config.url, config.email, config.token,
`/search/jql?jql=${encodeURIComponent(jql)}&maxResults=100&fields=summary,priority,status,reporter,created,attachment,description`
) as any
const bugs = (data.issues || []).map((issue: any) => ({
id: issue.id,
key: issue.key,
title: issue.fields.summary,
priority: issue.fields.priority?.name || '',
priorityId: issue.fields.priority?.id || '',
status: issue.fields.status?.name || '',
statusCategory: issue.fields.status?.statusCategory?.key || '',
reporter: issue.fields.reporter?.displayName || '',
created: issue.fields.created || '',
hasAttachments: (issue.fields.attachment || []).length > 0,
}))
res.json({ ok: true, bugs, total: data.total || bugs.length })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get single issue details
jiraRouter.get('/bugs/:key', async (req, res) => {
try {
const config = getJiraConfig()
if (!config.url) return res.json({ ok: false, error: 'Jira not configured' })
const data = await jiraFetch(config.url, config.email, config.token, `/issue/${req.params.key}?fields=summary,description,priority,status,attachment,reporter,created`)
res.json({ ok: true, issue: data })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Import bug from Jira into BugPack
jiraRouter.post('/import/:key', async (req, res) => {
try {
const config = getJiraConfig()
if (!config.url) return res.json({ ok: false, error: 'Jira not configured' })
const data = await jiraFetch(
config.url, config.email, config.token,
`/issue/${req.params.key}?fields=summary,description,priority,status,attachment`
) as any
const fields = data.fields
const projectId = req.body.projectId || ''
const bugId = crypto.randomUUID()
const now = new Date().toISOString()
// Get next number
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
const number = (last?.maxNum || 0) + 1
// Description: strip simple Jira markup
const rawDesc = fields.description || ''
const desc = `[Imported from Jira ${data.key}]\n\n${fields.summary}\n\n${rawDesc}`.trim()
// Priority mapping: Jira Highest/High -> high, Medium -> medium, Low/Lowest -> low
const priName = (fields.priority?.name || '').toLowerCase()
let priority = 'medium'
if (priName.includes('high') || priName.includes('critical') || priName.includes('blocker')) priority = 'high'
else if (priName.includes('low') || priName.includes('trivial')) priority = 'low'
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
bugId, number, fields.summary, desc, priority, projectId, now, now
)
// Download image attachments
const { writeFileSync, mkdirSync } = await import('fs')
const pathMod = await import('path')
const { UPLOADS_DIR } = await import('../db.js')
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
mkdirSync(projectDir, { recursive: true })
const headers = makeHeaders(config.email, config.token)
let imgIndex = 0
const attachments = fields.attachment || []
for (const att of attachments) {
const mimeType = (att.mimeType || '').toLowerCase()
if (!mimeType.startsWith('image/')) continue
try {
const imgRes = await fetch(att.content, { headers })
if (!imgRes.ok) continue
const buffer = Buffer.from(await imgRes.arrayBuffer())
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
const ext = mimeType.includes('jpeg') ? 'jpg' : mimeType.includes('png') ? 'png' : mimeType.includes('webp') ? 'webp' : mimeType.includes('gif') ? 'gif' : 'png'
const fname = `${bugId}-${imgIndex}.${ext}`
const filePath = pathMod.join(projectDir, fname)
writeFileSync(filePath, buffer)
const relPath = `${projectName}/${fname}`
const ssId = crypto.randomUUID()
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
ssId, bugId, relPath, att.filename || fname, att.filename || `Screenshot ${imgIndex + 1}`, imgIndex, now
)
imgIndex++
} catch {
// Skip failed downloads
}
}
res.json({ ok: true, bugId, number })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Sync status back to Jira (mark Issue as Done)
jiraRouter.post('/resolve/:key', async (req, res) => {
try {
const config = getJiraConfig()
if (!config.url) return res.json({ ok: false, error: 'Jira not configured' })
const headers = makeHeaders(config.email, config.token)
// Get available transitions
const transRes = await fetch(`${config.url}/rest/api/3/issue/${req.params.key}/transitions`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
if (!transRes.ok) throw new Error(`HTTP ${transRes.status}`)
const transData = await transRes.json() as any
// Find Done/Resolved type transition
const doneTrans = (transData.transitions || []).find((t: any) =>
t.to?.statusCategory?.key === 'done' ||
/done|resolved|完成|关闭/i.test(t.name)
)
if (!doneTrans) {
return res.json({ ok: false, error: 'No available done transition found' })
}
const doRes = await fetch(`${config.url}/rest/api/3/issue/${req.params.key}/transitions`, {
method: 'POST',
headers,
body: JSON.stringify({ transition: { id: doneTrans.id } }),
signal: AbortSignal.timeout(TIMEOUT),
})
if (!doRes.ok) throw new Error(`HTTP ${doRes.status}`)
res.json({ ok: true })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
@@ -0,0 +1,271 @@
import { Router } from 'express'
import crypto from 'crypto'
import { db } from '../db.js'
export const linearRouter = Router()
// Get Linear config
function getLinearConfig() {
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('linear%') as { key: string; value: string }[]
const config: Record<string, string> = {}
for (const row of rows) config[row.key] = row.value
return {
token: config.linearToken || '',
teamId: config.linearTeamId || '',
}
}
// Request timeout (15s)
const TIMEOUT = 15000
// Linear GraphQL request
async function linearQuery(token: string, query: string, variables?: Record<string, unknown>) {
const res = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(TIMEOUT),
})
const json = await res.json() as any
if (json.errors?.length) {
throw new Error(json.errors[0].message || 'Linear GraphQL error')
}
return json.data
}
// Test connection
linearRouter.post('/test', async (req, res) => {
try {
const { token } = req.body
if (!token) return res.json({ ok: false, error: 'Please enter API Key' })
const data = await linearQuery(token, `query { viewer { id name email } }`)
res.json({ ok: true, user: data.viewer?.name || data.viewer?.email })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get team list
linearRouter.get('/teams', async (_req, res) => {
try {
const config = getLinearConfig()
if (!config.token) return res.json({ ok: false, error: 'Linear API Key not configured' })
const data = await linearQuery(config.token, `query { teams { nodes { id name key } } }`)
res.json({
ok: true,
teams: (data.teams?.nodes || []).map((t: any) => ({ id: t.id, key: t.key, name: t.name })),
})
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get bug list (issues assigned to current user)
linearRouter.get('/bugs', async (_req, res) => {
try {
const config = getLinearConfig()
if (!config.token) return res.json({ ok: false, error: 'Linear API Key not configured' })
if (!config.teamId) return res.json({ ok: false, error: 'Please select a team first' })
const data = await linearQuery(config.token, `
query($teamId: ID!) {
viewer {
assignedIssues(
filter: {
team: { id: { eq: $teamId } }
state: { type: { nin: ["completed", "canceled"] } }
}
first: 100
orderBy: createdAt
) {
nodes {
id
identifier
title
priority
priorityLabel
state { id name type }
creator { name }
createdAt
attachments { nodes { id url title } }
}
}
}
}
`, { teamId: config.teamId })
const issues = data.viewer?.assignedIssues?.nodes || []
const bugs = issues.map((issue: any) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
priority: issue.priority,
priorityLabel: issue.priorityLabel || '',
status: issue.state?.name || '',
statusType: issue.state?.type || '',
creator: issue.creator?.name || '',
created: issue.createdAt || '',
hasAttachments: (issue.attachments?.nodes || []).length > 0,
}))
res.json({ ok: true, bugs, total: bugs.length })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Import issue from Linear into BugPack
linearRouter.post('/import/:id', async (req, res) => {
try {
const config = getLinearConfig()
if (!config.token) return res.json({ ok: false, error: 'Linear not configured' })
const data = await linearQuery(config.token, `
query($id: String!) {
issue(id: $id) {
id
identifier
title
description
priority
priorityLabel
attachments { nodes { id url title metadata } }
}
}
`, { id: req.params.id })
const issue = data.issue
if (!issue) return res.json({ ok: false, error: 'Issue not found' })
const projectId = req.body.projectId || ''
const bugId = crypto.randomUUID()
const now = new Date().toISOString()
// Get next number
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
const number = (last?.maxNum || 0) + 1
const desc = `[Imported from Linear ${issue.identifier}]\n\n${issue.title}\n\n${issue.description || ''}`.trim()
// Priority mapping: Linear 1=Urgent 2=High 3=Medium 4=Low 0=None
const priMap: Record<number, string> = { 0: 'medium', 1: 'high', 2: 'high', 3: 'medium', 4: 'low' }
const priority = priMap[issue.priority] || 'medium'
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
bugId, number, issue.title, desc, priority, projectId, now, now
)
// Download image attachments
const { writeFileSync, mkdirSync } = await import('fs')
const pathMod = await import('path')
const { UPLOADS_DIR } = await import('../db.js')
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
mkdirSync(projectDir, { recursive: true })
let imgIndex = 0
const attachments = issue.attachments?.nodes || []
for (const att of attachments) {
if (!att.url) continue
try {
// Try downloading attachment, check if image
const imgRes = await fetch(att.url, {
headers: { Authorization: config.token },
})
if (!imgRes.ok) continue
const contentType = (imgRes.headers.get('content-type') || '').toLowerCase()
if (!contentType.startsWith('image/')) continue
const buffer = Buffer.from(await imgRes.arrayBuffer())
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : contentType.includes('gif') ? 'gif' : 'png'
const fname = `${bugId}-${imgIndex}.${ext}`
const filePath = pathMod.join(projectDir, fname)
writeFileSync(filePath, buffer)
const relPath = `${projectName}/${fname}`
const ssId = crypto.randomUUID()
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
ssId, bugId, relPath, fname, att.title || `Screenshot ${imgIndex + 1}`, imgIndex, now
)
imgIndex++
} catch {
// Skip
}
}
// Also extract Markdown image links from description
const descImages = (issue.description || '').matchAll(/!\[.*?\]\((https?:\/\/[^\s)]+)\)/g)
for (const match of descImages) {
const imgUrl = match[1]
try {
const imgRes = await fetch(imgUrl, {
headers: { Authorization: config.token },
})
if (!imgRes.ok) continue
const contentType = (imgRes.headers.get('content-type') || '').toLowerCase()
if (!contentType.startsWith('image/')) continue
const buffer = Buffer.from(await imgRes.arrayBuffer())
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : contentType.includes('gif') ? 'gif' : 'png'
const fname = `${bugId}-${imgIndex}.${ext}`
const filePath = pathMod.join(projectDir, fname)
writeFileSync(filePath, buffer)
const relPath = `${projectName}/${fname}`
const ssId = crypto.randomUUID()
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
ssId, bugId, relPath, fname, `Screenshot ${imgIndex + 1}`, imgIndex, now
)
imgIndex++
} catch {
// Skip
}
}
res.json({ ok: true, bugId, number })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Sync status back to Linear (mark as completed)
linearRouter.post('/resolve/:id', async (req, res) => {
try {
const config = getLinearConfig()
if (!config.token) return res.json({ ok: false, error: 'Linear not configured' })
if (!config.teamId) return res.json({ ok: false, error: 'No team selected' })
// Find Done type status
const statesData = await linearQuery(config.token, `
query($teamId: ID!) {
team(id: $teamId) {
states { nodes { id name type } }
}
}
`, { teamId: config.teamId })
const doneState = (statesData.team?.states?.nodes || []).find((s: any) => s.type === 'completed')
if (!doneState) return res.json({ ok: false, error: 'No completed state found' })
await linearQuery(config.token, `
mutation($id: String!, $stateId: String!) {
issueUpdate(id: $id, input: { stateId: $stateId }) {
success
}
}
`, { id: req.params.id, stateId: doneState.id })
res.json({ ok: true })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
@@ -0,0 +1,235 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { db, UPLOADS_DIR } from '../db.js'
import crypto from 'crypto'
import { v4 as uuid } from 'uuid'
import archiver from 'archiver'
import AdmZip from 'adm-zip'
import multer from 'multer'
export const projectsRouter = Router()
// ZIP file upload (in-memory, max 200MB)
const zipUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 } })
// Get all projects
projectsRouter.get('/', (_req, res) => {
const projects = db.prepare('SELECT * FROM projects ORDER BY created_at DESC').all()
res.json(projects)
})
// Create project
projectsRouter.post('/', (req, res) => {
const { name } = req.body
if (!name) return res.status(400).json({ error: 'Project name is required' })
const id = crypto.randomUUID()
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run(id, name)
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(id)
res.json(project)
})
// Rename project
projectsRouter.patch('/:id', (req, res) => {
const { name } = req.body
db.prepare('UPDATE projects SET name = ? WHERE id = ?').run(name, req.params.id)
res.json({ ok: true })
})
// Delete project (also deletes associated bugs and screenshot files)
projectsRouter.delete('/:id', (req, res) => {
const bugs: any[] = db.prepare('SELECT id FROM bugs WHERE project_id = ?').all(req.params.id)
for (const bug of bugs) {
const screenshots: any[] = db.prepare('SELECT filename FROM screenshots WHERE bug_id = ?').all(bug.id)
for (const ss of screenshots) {
const filePath = path.join(UPLOADS_DIR, ss.filename)
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
}
db.prepare('DELETE FROM screenshots WHERE bug_id = ?').run(bug.id)
}
db.prepare('DELETE FROM bugs WHERE project_id = ?').run(req.params.id)
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id)
res.json({ ok: true })
})
// Export project data (ZIP: manifest.json + original image files, streaming)
projectsRouter.get('/:id/export', (req, res) => {
const project: any = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id)
if (!project) return res.status(404).json({ error: 'Project not found' })
const bugs: any[] = db.prepare('SELECT * FROM bugs WHERE project_id = ?').all(req.params.id)
const manifest: any = {
version: '2.0',
exportedAt: new Date().toISOString(),
project: { name: project.name, created_at: project.created_at },
bugs: [],
}
// Collect screenshot file info
const imageFiles: { zipPath: string; diskPath: string }[] = []
for (const bug of bugs) {
const screenshots: any[] = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id)
const ssExport = screenshots.map((ss: any, i: number) => {
const ext = path.extname(ss.filename).toLowerCase() || '.png'
const zipPath = `images/${bug.number}/${i}${ext}`
const diskPath = path.join(UPLOADS_DIR, ss.filename)
if (fs.existsSync(diskPath)) {
imageFiles.push({ zipPath, diskPath })
}
return {
original_name: ss.original_name,
name: ss.name,
annotated: ss.annotated,
sort_order: ss.sort_order,
annotations: ss.annotations,
imagePath: fs.existsSync(diskPath) ? zipPath : null,
}
})
manifest.bugs.push({
number: bug.number,
title: bug.title,
description: bug.description,
status: bug.status,
priority: bug.priority,
page_path: bug.page_path,
device: bug.device,
browser: bug.browser,
related_files: bug.related_files,
created_at: bug.created_at,
updated_at: bug.updated_at,
screenshots: ssExport,
})
}
// Stream ZIP output
const filename = `bugpack-${project.name}-${new Date().toISOString().split('T')[0]}.zip`
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
res.setHeader('Content-Type', 'application/zip')
const archive = archiver('zip', { zlib: { level: 6 } })
archive.on('error', (err: Error) => {
console.error('ZIP archive error:', err)
if (!res.headersSent) res.status(500).json({ error: err.message })
})
archive.pipe(res)
// Write manifest
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' })
// Stream image files (no full memory load)
for (const img of imageFiles) {
archive.file(img.diskPath, { name: img.zipPath })
}
archive.finalize()
})
// Import project data (receive ZIP file)
projectsRouter.post('/:id/import', zipUpload.single('file'), (req, res) => {
try {
const projectId = req.params.id
const project: any = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId)
if (!project) return res.status(404).json({ error: 'Project not found' })
if (!req.file) return res.status(400).json({ error: 'Please upload a .zip file' })
const zip = new AdmZip(req.file.buffer)
const manifestEntry = zip.getEntry('manifest.json')
if (!manifestEntry) return res.status(400).json({ error: 'Invalid BugPack backup file (missing manifest.json)' })
const manifest = JSON.parse(manifestEntry.getData().toString('utf-8'))
if (!manifest.bugs || !Array.isArray(manifest.bugs)) {
return res.status(400).json({ error: 'Invalid manifest data' })
}
if (manifest.bugs.length > 5000) {
return res.status(400).json({ error: 'Too many bugs in import (max 5000)' })
}
const projectName = (project.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
const projectDir = path.join(UPLOADS_DIR, projectName)
fs.mkdirSync(projectDir, { recursive: true })
let importedCount = 0
const importAll = db.transaction(() => {
for (const bugData of manifest.bugs) {
const bugId = uuid()
const now = new Date().toISOString()
const last: any = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId)
const number = (last?.maxNum || 0) + 1
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
bugId, number,
bugData.title || '',
bugData.description || '',
bugData.status || 'pending',
bugData.priority || 'medium',
bugData.page_path || '',
bugData.device || '',
bugData.browser || '',
bugData.related_files || '[]',
projectId,
bugData.created_at || now,
bugData.updated_at || now,
)
// Import screenshots
if (Array.isArray(bugData.screenshots)) {
for (let i = 0; i < bugData.screenshots.length; i++) {
const ssData = bugData.screenshots[i]
if (!ssData?.imagePath) continue
// Path traversal protection
if (ssData.imagePath.includes('..') || path.isAbsolute(ssData.imagePath)) continue
// Extract image from ZIP
const imgEntry = zip.getEntry(ssData.imagePath)
if (!imgEntry) continue
if (imgEntry.header.size > 50 * 1024 * 1024) continue // Skip files > 50MB
const buffer = imgEntry.getData()
const ext = path.extname(ssData.imagePath) || '.png'
const fname = `${bugId}-${i}${ext}`
const filePath = path.join(projectDir, fname)
// Verify resolved path is within project dir
const resolved = path.resolve(filePath)
if (!resolved.startsWith(path.resolve(projectDir) + path.sep)) continue
fs.writeFileSync(filePath, buffer)
const relPath = `${projectName}/${fname}`
const ssId = uuid()
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
ssId, bugId, relPath,
ssData.original_name || fname,
ssData.name || `截图 ${i + 1}`,
ssData.annotated ? 1 : 0,
ssData.sort_order ?? i,
ssData.annotations || '[]',
now,
)
}
}
importedCount++
}
})
importAll()
res.json({ ok: true, importedCount })
} catch (e: any) {
console.error('Import failed:', e)
res.json({ ok: false, error: 'Import failed. Please check the file format.' })
}
})
@@ -0,0 +1,76 @@
import { Router } from 'express'
import { execFile } from 'child_process'
import { writeFileSync, readFileSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import path from 'path'
import { db, DATA_DIR } from '../db.js'
export const settingsRouter = Router()
// Get all settings
settingsRouter.get('/', (_req, res) => {
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[]
const result: Record<string, string> = {}
for (const row of rows) {
result[row.key] = row.value
}
// Return server data directory
result._dataDir = DATA_DIR
result._cwd = process.cwd()
res.json(result)
})
// Pick directory (native Windows dialog, foreground, returns full path)
settingsRouter.post('/pick-directory', (_req, res) => {
const resultPath = path.join(tmpdir(), 'bugpack-pick-result.txt')
const scriptPath = path.join(tmpdir(), 'bugpack-pick-dir.ps1')
// Create a TopMost hidden form as parent to ensure dialog appears in foreground
const ps = `
Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.TopMost = $true
$form.WindowState = 'Minimized'
$form.ShowInTaskbar = $false
$form.Show()
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
$dialog.Description = 'Select Directory'
$dialog.ShowNewFolderButton = $true
$result = $dialog.ShowDialog($form)
$form.Close()
if ($result -eq 'OK') {
[System.IO.File]::WriteAllText('${resultPath.replace(/\\/g, '\\\\')}', $dialog.SelectedPath)
} else {
[System.IO.File]::WriteAllText('${resultPath.replace(/\\/g, '\\\\')}', '')
}
`
writeFileSync(scriptPath, ps, 'utf-8')
execFile('powershell', ['-ExecutionPolicy', 'Bypass', '-File', scriptPath], {
timeout: 60000,
}, (err) => {
try { unlinkSync(scriptPath) } catch {}
if (err) {
console.error('Failed to open directory picker:', err)
try { unlinkSync(resultPath) } catch {}
return res.json({ path: '' })
}
let selected = ''
try {
selected = readFileSync(resultPath, 'utf-8').trim()
unlinkSync(resultPath)
} catch {}
res.json({ path: selected })
})
})
// Batch save settings
settingsRouter.put('/', (req, res) => {
const data = req.body as Record<string, string>
const upsert = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?')
const tx = db.transaction(() => {
for (const [key, value] of Object.entries(data)) {
upsert.run(key, value, value)
}
})
tx()
res.json({ ok: true })
})
+280
View File
@@ -0,0 +1,280 @@
import { Router } from 'express'
import crypto from 'crypto'
import { db } from '../db.js'
export const tapdRouter = Router()
// Get TAPD config
function getTapdConfig() {
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('tapd%') as { key: string; value: string }[]
const config: Record<string, string> = {}
for (const row of rows) config[row.key] = row.value
return {
apiUser: config.tapdApiUser || '',
apiPassword: config.tapdApiPassword || '',
workspaceId: config.tapdWorkspaceId || '',
}
}
// Build Basic Auth header
function makeHeaders(apiUser: string, apiPassword: string): Record<string, string> {
return {
Authorization: `Basic ${Buffer.from(`${apiUser}:${apiPassword}`).toString('base64')}`,
}
}
// Request timeout (15s)
const TIMEOUT = 15000
// TAPD API request
async function tapdFetch(apiUser: string, apiPassword: string, path: string) {
const headers = makeHeaders(apiUser, apiPassword)
const res = await fetch(`https://api.tapd.cn${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`TAPD request failed: HTTP ${res.status} ${text.slice(0, 200)}`)
}
const json = await res.json() as any
if (json.status !== 1) {
throw new Error(json.info || 'TAPD API returned error')
}
return json.data
}
// Test connection
tapdRouter.post('/test', async (req, res) => {
try {
const { apiUser, apiPassword } = req.body
if (!apiUser || !apiPassword) {
return res.json({ ok: false, error: 'Please enter API credentials' })
}
const headers = makeHeaders(apiUser, apiPassword)
const testRes = await fetch('https://api.tapd.cn/quickstart/testauth', { headers, signal: AbortSignal.timeout(TIMEOUT) })
if (!testRes.ok) {
if (testRes.status === 401) throw new Error('Invalid API credentials')
throw new Error(`HTTP ${testRes.status}`)
}
const json = await testRes.json() as any
if (json.status !== 1) throw new Error(json.info || 'Authentication failed')
res.json({ ok: true })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get project (workspace) list
// TAPD project list API requires company_id; use configured workspace_id instead
tapdRouter.get('/workspaces', async (_req, res) => {
try {
const config = getTapdConfig()
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD API account not configured' })
if (!config.workspaceId) return res.json({ ok: false, error: 'Please set TAPD workspace ID in settings' })
// Return configured workspace directly, skip project selection
res.json({ ok: true, workspaces: [{ id: config.workspaceId, name: `Project #${config.workspaceId}` }] })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get bug list
tapdRouter.get('/bugs', async (_req, res) => {
try {
const config = getTapdConfig()
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
if (!config.workspaceId) return res.json({ ok: false, error: 'Please select a project first' })
const data = await tapdFetch(
config.apiUser, config.apiPassword,
`/bugs?workspace_id=${config.workspaceId}&limit=100&order=created desc`
)
const bugs = (data || []).map((item: any) => {
const b = item.Bug || item
return {
id: b.id,
title: b.title,
severity: b.severity || '',
priority: b.priority_label || b.priority || '',
status: b.status || '',
reporter: b.reporter || '',
currentOwner: b.current_owner || '',
created: b.created || '',
}
})
res.json({ ok: true, bugs, total: bugs.length })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get single bug details
tapdRouter.get('/bugs/:id', async (req, res) => {
try {
const config = getTapdConfig()
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
const data = await tapdFetch(
config.apiUser, config.apiPassword,
`/bugs?workspace_id=${config.workspaceId}&id=${req.params.id}`
)
const bug = data?.[0]?.Bug || null
res.json({ ok: true, bug })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Import bug from TAPD into BugPack
tapdRouter.post('/import/:id', async (req, res) => {
try {
const config = getTapdConfig()
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
// Get bug details
const bugData = await tapdFetch(
config.apiUser, config.apiPassword,
`/bugs?workspace_id=${config.workspaceId}&id=${req.params.id}`
)
const tapdBug = bugData?.[0]?.Bug
if (!tapdBug) return res.json({ ok: false, error: 'Bug not found' })
const projectId = req.body.projectId || ''
const bugId = crypto.randomUUID()
const now = new Date().toISOString()
// Get next number
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
const number = (last?.maxNum || 0) + 1
// Description: strip HTML tags
const rawDesc = (tapdBug.description || '').replace(/<[^>]+>/g, '')
const desc = `[Imported from TAPD #${tapdBug.id}]\n\n${tapdBug.title}\n\n${rawDesc}`.trim()
// Priority mapping
const priLabel = (tapdBug.priority_label || tapdBug.priority || '').toLowerCase()
let priority = 'medium'
if (priLabel.includes('紧急') || priLabel.includes('urgent') || priLabel.includes('high')) priority = 'high'
else if (priLabel.includes('低') || priLabel.includes('low')) priority = 'low'
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
bugId, number, tapdBug.title, desc, priority, projectId, now, now
)
// Download image attachments
const { writeFileSync, mkdirSync } = await import('fs')
const pathMod = await import('path')
const { UPLOADS_DIR } = await import('../db.js')
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
mkdirSync(projectDir, { recursive: true })
let imgIndex = 0
// Save image helper
const saveImage = (buffer: Buffer, ext: string, name: string) => {
const fname = `${bugId}-${imgIndex}.${ext}`
const filePath = pathMod.join(projectDir, fname)
writeFileSync(filePath, buffer)
const relPath = `${projectName}/${fname}`
const ssId = crypto.randomUUID()
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
ssId, bugId, relPath, fname, name, imgIndex, now
)
imgIndex++
}
// Get temporary download link via get_image API
const downloadViaGetImage = async (imagePath: string, name: string) => {
const imgData = await tapdFetch(
config.apiUser, config.apiPassword,
`/files/get_image?workspace_id=${config.workspaceId}&image_path=${encodeURIComponent(imagePath)}`
)
const downloadUrl = imgData?.Attachment?.download_url
if (!downloadUrl) return false
// download_url is a temporary link (300s), no auth needed
const imgRes = await fetch(downloadUrl)
if (!imgRes.ok) return false
const contentType = (imgRes.headers.get('content-type') || '').toLowerCase()
if (!contentType.startsWith('image')) return false
const buffer = Buffer.from(await imgRes.arrayBuffer())
if (buffer.length > 20 * 1024 * 1024) return false // Skip images over 20MB
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : 'png'
saveImage(buffer, ext, name)
return true
}
// 1) Extract /tfl/ path images from description HTML, download via get_image API
const descHtml = tapdBug.description || ''
const tflRegex = /(?:src=["']|")(\/tfl\/[^"']+)["']/gi
let match
while ((match = tflRegex.exec(descHtml)) !== null) {
try {
await downloadViaGetImage(match[1]!, `Screenshot ${imgIndex + 1}`)
} catch {
// Skip
}
}
// 2) Get bug attachment list, download using filename path
try {
const attData = await tapdFetch(
config.apiUser, config.apiPassword,
`/attachments?workspace_id=${config.workspaceId}&entry_id=${req.params.id}&limit=50`
)
for (const item of (attData || [])) {
const att = item.Attachment || item
const filename = (att.filename || '').toLowerCase()
if (!filename.match(/\.(png|jpg|jpeg|gif|webp|bmp)$/)) continue
try {
// Try downloading via get_image using attachment filename
const success = await downloadViaGetImage(att.filename, att.filename || `Screenshot ${imgIndex + 1}`)
if (!success) {
// Fallback: try common TAPD file path
await downloadViaGetImage(`/tfl/pictures/${att.filename}`, att.filename || `Screenshot ${imgIndex + 1}`)
}
} catch {
// skip
}
}
} catch {
// Attachment fetch failure does not block import
}
res.json({ ok: true, bugId, number })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Sync status back to TAPD (close bug)
tapdRouter.post('/resolve/:id', async (req, res) => {
try {
const config = getTapdConfig()
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
const headers = makeHeaders(config.apiUser, config.apiPassword)
const apiRes = await fetch('https://api.tapd.cn/bugs', {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
workspace_id: config.workspaceId,
id: req.params.id,
status: 'resolved',
}).toString(),
signal: AbortSignal.timeout(TIMEOUT),
})
if (!apiRes.ok) throw new Error(`HTTP ${apiRes.status}`)
const json = await apiRes.json() as any
if (json.status !== 1) throw new Error(json.info || 'Update failed')
res.json({ ok: true })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
@@ -0,0 +1,306 @@
import { Router } from 'express'
import crypto from 'crypto'
import { db } from '../db.js'
export const zentaoRouter = Router()
// Clean URL: strip trailing /my.html, /index.html, / etc.
function cleanUrl(raw: string): string {
let url = raw.trim().replace(/\/+$/, '')
// Strip trailing .html/.php page path
url = url.replace(/\/[^/]*\.(html|php)$/i, '')
return url
}
// Get Zentao config
function getZentaoConfig() {
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('zentao%') as { key: string; value: string }[]
const config: Record<string, string> = {}
for (const row of rows) config[row.key] = row.value
return {
url: cleanUrl(config.zentaoUrl || ''),
// HTTP Basic Auth (company gateway auth)
httpUser: config.zentaoHttpUser || '',
httpPass: config.zentaoHttpPass || '',
// Zentao system account
account: config.zentaoAccount || '',
password: config.zentaoPassword || '',
productId: config.zentaoProductId || '',
}
}
// Build Basic Auth header (company gateway auth)
function makeBasicHeaders(httpUser: string, httpPass: string, extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = { ...extra }
if (httpUser) {
headers.Authorization = `Basic ${Buffer.from(`${httpUser}:${httpPass}`).toString('base64')}`
}
return headers
}
// Request timeout (15s)
const TIMEOUT = 15000
// Get token
async function getToken(baseUrl: string, account: string, password: string, httpUser: string, httpPass: string): Promise<string> {
const res = await fetch(`${baseUrl}/api.php/v1/tokens`, {
method: 'POST',
headers: makeBasicHeaders(httpUser, httpPass, { 'Content-Type': 'application/json' }),
body: JSON.stringify({ account, password }),
signal: AbortSignal.timeout(TIMEOUT),
})
const text = await res.text().catch(() => '')
let json: any = null
try { json = JSON.parse(text) } catch {}
if (!res.ok || json?.error) {
const msg = json?.error || `HTTP ${res.status}`
throw new Error(msg)
}
if (!json?.token) throw new Error('Zentao did not return a token')
return json.token
}
// Zentao API request
async function zentaoFetch(baseUrl: string, token: string, path: string, httpUser: string, httpPass: string) {
const headers = makeBasicHeaders(httpUser, httpPass, { Token: token })
const res = await fetch(`${baseUrl}/api.php/v1${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
if (!res.ok) throw new Error(`Zentao request failed: HTTP ${res.status}`)
return res.json()
}
// Test connection
zentaoRouter.post('/test', async (req, res) => {
try {
const { url, account, password, httpUser, httpPass } = req.body
const base = cleanUrl(url)
const token = await getToken(base, account, password, httpUser || '', httpPass || '')
res.json({ ok: true, token })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get project list (for selection)
zentaoRouter.get('/products', async (_req, res) => {
try {
const config = getZentaoConfig()
if (!config.url) return res.json({ ok: false, error: 'Zentao URL not configured' })
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
const data = await zentaoFetch(config.url, token, '/projects?limit=100', config.httpUser, config.httpPass) as any
// Return project list (reuse 'products' field name for frontend compatibility)
res.json({ ok: true, products: (data.projects || []).map((p: any) => ({ id: p.id, name: p.name })) })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get bug list (by project ID, filter to current user)
zentaoRouter.get('/bugs', async (_req, res) => {
try {
const config = getZentaoConfig()
if (!config.url) return res.json({ ok: false, error: 'Zentao URL not configured' })
if (!config.productId) return res.json({ ok: false, error: 'Please select a project first' })
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
const data = await zentaoFetch(config.url, token, `/projects/${config.productId}/bugs?limit=200`, config.httpUser, config.httpPass) as any
// Filter bugs assigned to current user
const allBugs = data.bugs || []
const myBugs = allBugs.filter((b: any) => {
const assigned = b.assignedTo
const account = typeof assigned === 'string' ? assigned : assigned?.account
return account === config.account
})
res.json({ ok: true, bugs: myBugs, total: allBugs.length })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Get single bug details (raw data for debugging image fields)
zentaoRouter.get('/bugs/:id', async (req, res) => {
try {
const config = getZentaoConfig()
if (!config.url) return res.json({ ok: false, error: 'Zentao not configured' })
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
const data = await zentaoFetch(config.url, token, `/bugs/${req.params.id}`, config.httpUser, config.httpPass)
res.json({ ok: true, bug: data })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Import bug from Zentao into BugPack
zentaoRouter.post('/import/:id', async (req, res) => {
try {
const config = getZentaoConfig()
if (!config.url) return res.json({ ok: false, error: 'Zentao not configured' })
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
const data = await zentaoFetch(config.url, token, `/bugs/${req.params.id}`, config.httpUser, config.httpPass) as any
const projectId = req.body.projectId || ''
const bugId = crypto.randomUUID()
const now = new Date().toISOString()
// Get next number
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
const number = (last?.maxNum || 0) + 1
// Build description
const steps = data.steps ? data.steps.replace(/<[^>]+>/g, '') : ''
let desc = `[Imported from Zentao #${data.id}]\n\n${data.title}\n\n${steps}`.trim()
// Append history/comments from bug detail, collect comment image fileIDs
const commentFileIds: string[] = []
try {
const actionList = data.actions || []
if (Array.isArray(actionList) && actionList.length > 0) {
const historyLines: string[] = ['\n\n---\n## History']
for (const act of actionList) {
const time = act.date || ''
const actor = act.actor || ''
const action = act.action || ''
const rawComment = act.comment || ''
// Extract image fileIDs from comment HTML
const fidRegex = /fileID=(\d+)/g
let fidMatch
while ((fidMatch = fidRegex.exec(rawComment)) !== null) {
if (fidMatch[1]) commentFileIds.push(fidMatch[1])
}
const comment = rawComment.replace(/<[^>]+>/g, '').trim()
let line = `- **${time}** ${actor} ${action}`
if (comment) line += `\n > ${comment}`
historyLines.push(line)
}
desc += historyLines.join('\n')
}
} catch {
// History parse failed, skip
}
// Priority mapping: Zentao 1=Highest 2=High 3=Medium 4=Low -> BugPack high/medium/low
const priMap: Record<number, string> = { 1: 'high', 2: 'high', 3: 'medium', 4: 'low' }
const priority = priMap[data.pri] || 'medium'
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
bugId, number, data.title, desc, priority, projectId, now, now
)
// Download images: extract inline images from steps HTML + file attachments
const { writeFileSync, mkdirSync } = await import('fs')
const pathMod = await import('path')
const { UPLOADS_DIR } = await import('../db.js')
// Get project upload directory (consistent with bugs route)
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
mkdirSync(projectDir, { recursive: true })
const fileHeaders = makeBasicHeaders(config.httpUser, config.httpPass, { Token: token })
let imgIndex = 0
// Save image helper
const saveImage = (buffer: Buffer, ext: string, name: string) => {
const fname = `${bugId}-${imgIndex}.${ext}`
const filePath = pathMod.join(projectDir, fname)
writeFileSync(filePath, buffer)
// Store path relative to UPLOADS_DIR
const relPath = `${projectName}/${fname}`
const ssId = crypto.randomUUID()
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
ssId, bugId, relPath, fname, name, imgIndex, now
)
imgIndex++
}
// 1) Extract images from steps (via fileID parameter)
const stepsHtml = data.steps || ''
// Match fileID=number pattern (Zentao standard image reference)
const fileIdRegex = /fileID=(\d+)/g
const seenFileIds = new Set<string>()
let match
while ((match = fileIdRegex.exec(stepsHtml)) !== null) {
const fileId = match[1] ?? ''
if (!fileId || seenFileIds.has(fileId)) continue
seenFileIds.add(fileId)
try {
// Use Zentao standard file download API
const imgUrl = `${config.url}/api.php?m=file&f=read&fileID=${fileId}`
const imgRes = await fetch(imgUrl, { headers: fileHeaders })
if (!imgRes.ok) continue
const contentType = imgRes.headers.get('content-type') || ''
if (!contentType.startsWith('image/')) continue
const contentLength = parseInt(imgRes.headers.get('content-length') || '0')
if (contentLength > 20 * 1024 * 1024) continue // Skip images over 20MB
const buffer = Buffer.from(await imgRes.arrayBuffer())
if (buffer.length > 20 * 1024 * 1024) continue
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : 'png'
saveImage(buffer, ext, `Screenshot ${imgIndex + 1}`)
} catch {
// Skip
}
}
// 2) Images from history/comments
for (const fileId of commentFileIds) {
if (seenFileIds.has(fileId)) continue
seenFileIds.add(fileId)
try {
const imgUrl = `${config.url}/api.php?m=file&f=read&fileID=${fileId}`
const imgRes = await fetch(imgUrl, { headers: fileHeaders })
if (!imgRes.ok) continue
const contentType = imgRes.headers.get('content-type') || ''
if (!contentType.startsWith('image/')) continue
const buffer = Buffer.from(await imgRes.arrayBuffer())
if (buffer.length > 20 * 1024 * 1024) continue
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : 'png'
saveImage(buffer, ext, `Screenshot ${imgIndex + 1}`)
} catch {
// Skip
}
}
// 3) Images from file attachments
if (data.files && Array.isArray(data.files)) {
for (const file of data.files) {
if (!file.pathname) continue
const ext = (file.extension || '').toLowerCase()
if (!['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(ext)) continue
try {
const fileUrl = `${config.url}/data/upload/${file.pathname}`
const fileRes = await fetch(fileUrl, { headers: fileHeaders })
if (!fileRes.ok) continue
const buffer = Buffer.from(await fileRes.arrayBuffer())
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
saveImage(buffer, ext, file.title || `Screenshot ${imgIndex + 1}`)
} catch {
// Skip
}
}
}
res.json({ ok: true, bugId, number })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
// Sync status back to Zentao (resolve bug)
zentaoRouter.post('/resolve/:id', async (req, res) => {
try {
const config = getZentaoConfig()
if (!config.url) return res.json({ ok: false, error: 'Zentao not configured' })
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
const headers = makeBasicHeaders(config.httpUser, config.httpPass, { 'Content-Type': 'application/json', Token: token })
const apiRes = await fetch(`${config.url}/api.php/v1/bugs/${req.params.id}/resolve`, {
method: 'POST',
headers,
body: JSON.stringify({ resolution: req.body.resolution || 'fixed' }),
signal: AbortSignal.timeout(TIMEOUT),
})
if (!apiRes.ok) throw new Error(`HTTP ${apiRes.status}`)
res.json({ ok: true })
} catch (e: any) {
res.json({ ok: false, error: e.message })
}
})
+74
View File
@@ -0,0 +1,74 @@
@echo off
chcp 65001 >nul
title BugPack 一键启动脚本
:: ============================================
:: BugPack 一键启动脚本
:: ============================================
setlocal EnableDelayedExpansion
:: 项目目录(脚本所在目录)
set "BUGPACK_DIR=%~dp0"
set "PORT=3457"
set "MSYS2_PATH=C:\msys64"
echo [信息] MSYS2 环境: !MSYS2_PATH!
:: 检查 node_modules
if not exist "%BUGPACK_DIR%node_modules" (
echo [警告] 未找到 node_modules,需要安装依赖...
echo [信息] 正在安装依赖,请稍候...
"!MSYS2_PATH!\usr\bin\bash.exe" -c "cd '%BUGPACK_DIR%' && export PATH='/mingw64/bin:/usr/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm install"
if errorlevel 1 (
echo [错误] 依赖安装失败!
pause
exit /b 1
)
echo [信息] 依赖安装完成
) else (
echo [信息] node_modules 已存在
)
:: 检查 dist
if not exist "%BUGPACK_DIR%dist\client\index.html" (
echo [警告] 未找到构建产物,需要构建...
"!MSYS2_PATH!\usr\bin\bash.exe" -c "cd '%BUGPACK_DIR%' && export PATH='/mingw64/bin:/usr/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm run build"
if errorlevel 1 (
echo [错误] 构建失败!
pause
exit /b 1
)
echo [信息] 构建完成
) else (
echo [信息] 构建产物已存在
)
:: 检查端口
set "ORIGINAL_PORT=%PORT%"
:check_port
netstat -ano | findstr ":%PORT% " | findstr "LISTENING" >nul
if not errorlevel 1 (
set /a PORT+=1
goto :check_port
)
:: 启动
echo.
echo ============================================
echo BugPack 服务启动中...
echo ============================================
echo.
if not "%ORIGINAL_PORT%"=="%PORT%" (
echo [提示] 端口 %ORIGINAL_PORT% 被占用,已切换到 %PORT%
echo.
)
echo [信息] 访问地址: http://localhost:%PORT%
echo.
"!MSYS2_PATH!\usr\bin\bash.exe" -c "cd '%BUGPACK_DIR%' && export PATH='/mingw64/bin:/usr/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && PORT=%PORT% npm run dev:server"
echo.
echo [信息] 服务已停止
echo.
pause
+128
View File
@@ -0,0 +1,128 @@
# BugPack 一键启动脚本
# 功能:自动配置环境并启动 BugPack 服务
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$Host.UI.RawUI.WindowTitle = "BugPack 一键启动脚本"
# 项目目录
$BUGPACK_DIR = Split-Path -Parent $MyInvocation.MyCommand.Definition
$PORT = 3457
# 自动检测 MSYS2 安装路径
$MSYS2_PATHS = @("C:\msys64", "D:\msys64", "E:\msys64")
$MSYS2_PATH = $null
foreach ($path in $MSYS2_PATHS) {
if (Test-Path "$path\usr\bin\bash.exe") {
$MSYS2_PATH = $path
break
}
}
if (-not $MSYS2_PATH) {
Write-Host "[错误] 未找到 MSYS2 环境!" -ForegroundColor Red
Write-Host ""
Write-Host "请确认 MSYS2 已安装,或手动修改脚本中的 MSYS2_PATHS 变量。"
Write-Host "常见安装路径:C:\msys64, D:\msys64"
Write-Host ""
Read-Host "按回车键退出"
exit 1
}
Write-Host "[信息] MSYS2 环境已找到: $MSYS2_PATH" -ForegroundColor Green
# 检查 node_modules 是否存在
if (-not (Test-Path "$BUGPACK_DIR\node_modules")) {
Write-Host "[警告] 未找到 node_modules,需要安装依赖..." -ForegroundColor Yellow
Write-Host "[信息] 正在安装依赖,请稍候(可能需要几分钟)..."
Write-Host ""
$env:MSYSTEM = "MINGW64"
$env:PATH = "$MSYS2_PATH\mingw64\bin;$MSYS2_PATH\usr\bin;$env:PATH"
Set-Location $BUGPACK_DIR
& "$MSYS2_PATH\usr\bin\bash.exe" -lc "export PATH='/mingw64/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm install"
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "[错误] 依赖安装失败!" -ForegroundColor Red
Write-Host "[提示] 请检查网络连接,或手动运行:npm install"
Read-Host "按回车键退出"
exit 1
}
Write-Host "[信息] 依赖安装完成" -ForegroundColor Green
} else {
Write-Host "[信息] node_modules 已存在,跳过安装" -ForegroundColor Green
}
# 检查 dist 目录是否存在(构建产物)
if (-not (Test-Path "$BUGPACK_DIR\dist\client\index.html")) {
Write-Host "[警告] 未找到构建产物,需要构建项目..." -ForegroundColor Yellow
Write-Host "[信息] 正在构建项目,请稍候..."
Write-Host ""
$env:MSYSTEM = "MINGW64"
$env:PATH = "$MSYS2_PATH\mingw64\bin;$MSYS2_PATH\usr\bin;$env:PATH"
Set-Location $BUGPACK_DIR
& "$MSYS2_PATH\usr\bin\bash.exe" -lc "export PATH='/mingw64/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm run build"
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "[错误] 项目构建失败!" -ForegroundColor Red
Write-Host "[提示] 请检查 src/server/index.ts 中的静态文件路径配置"
Read-Host "按回车键退出"
exit 1
}
Write-Host "[信息] 项目构建完成" -ForegroundColor Green
} else {
Write-Host "[信息] 构建产物已存在,跳过构建" -ForegroundColor Green
}
# 检查端口是否被占用,如果被占用则端口号+1,直到找到可用端口
$ORIGINAL_PORT = $PORT
Write-Host "[信息] 检查端口 $PORT 是否可用..."
while ($true) {
$connection = Get-NetTCPConnection -LocalPort $PORT -ErrorAction SilentlyContinue
if (-not $connection) {
break
}
Write-Host "[警告] 端口 $PORT 已被占用,尝试端口 $($PORT+1)..." -ForegroundColor Yellow
$PORT++
}
Write-Host "[信息] 端口 $PORT 可用!" -ForegroundColor Green
# 启动服务
Write-Host ""
Write-Host "============================================"
Write-Host " BugPack 服务启动中..."
Write-Host "============================================"
Write-Host ""
if ($ORIGINAL_PORT -ne $PORT) {
Write-Host "[提示] 默认端口 $ORIGINAL_PORT 被占用,已自动切换到端口 $PORT" -ForegroundColor Cyan
Write-Host ""
}
Write-Host "[信息] 项目目录: $BUGPACK_DIR"
Write-Host "[信息] 访问地址: http://localhost:$PORT" -ForegroundColor Green
Write-Host ""
Write-Host "[提示] 按 Ctrl+C 可停止服务"
Write-Host ""
# 使用 MSYS2 bash 启动服务
$env:MSYSTEM = "MINGW64"
$env:PATH = "$MSYS2_PATH\mingw64\bin;$MSYS2_PATH\usr\bin;$env:PATH"
Set-Location $BUGPACK_DIR
& "$MSYS2_PATH\usr\bin\bash.exe" -lc "export PATH='/mingw64/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && PORT=$PORT npm run dev:server"
# 服务停止后的提示
Write-Host ""
Write-Host "============================================"
Write-Host "[信息] BugPack 服务已停止"
Write-Host "============================================"
Write-Host ""
Read-Host "按回车键退出"
+50
View File
@@ -0,0 +1,50 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
bg: {
primary: 'var(--bg-primary)',
sidebar: 'var(--bg-sidebar)',
card: 'var(--bg-card)',
input: 'var(--bg-input)',
hover: 'var(--bg-hover)',
},
border: {
DEFAULT: 'var(--border)',
},
accent: {
DEFAULT: 'var(--accent)',
hover: 'var(--accent-hover)',
},
cyan: {
DEFAULT: '#22D3EE',
},
status: {
pending: '#EF4444',
annotating: '#F59E0B',
generated: '#3B82F6',
fixed: '#22C55E',
closed: '#6B7280',
},
text: {
primary: 'var(--text-primary)',
secondary: 'var(--text-secondary)',
muted: 'var(--text-muted)',
},
},
fontFamily: {
sans: [
'PingFang SC',
'Microsoft YaHei',
'system-ui',
'-apple-system',
'sans-serif',
],
},
},
},
plugins: [],
} satisfies Config
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/client/*"]
}
},
"include": ["src"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false
},
"include": ["src/server", "src/mcp"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist/client',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src/client'),
},
},
server: {
host: '0.0.0.0',
port: 3456,
proxy: {
'/api': 'http://localhost:3457',
'/uploads': 'http://localhost:3457',
},
},
})
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+102
View File
@@ -0,0 +1,102 @@
# QuanYiXiaoZhuShou 测试项目
## 项目概述
本项目是一个**后端测试项目**,主要用于对后端 SDK API 进行自动化测试、微信小程序交互黑盒测试以及 Web 端管理后台的自动化测试。
## 项目结构
```
test/
├── .trae/
│ └── skills/
│ └── bugpack-deploy/ # BugPack 部署 Skill
├── BugPack-main/ # BugPack Bug 管理工具(已部署)
│ ├── src/
│ │ ├── client/ # 前端 React 应用
│ │ ├── server/ # 后端 Express API
│ │ └── mcp/ # MCP Server 实现
│ ├── dist/ # 构建产物
│ ├── start-bugpack.bat # 一键启动脚本
│ └── package.json
├── BugPack.zip # BugPack 源码包
├── msys2-base.tar.xz # MSYS2 基础包
├── msys2-installer.exe # MSYS2 安装程序
└── node.zip # Node.js 便携包
```
## 当前状态
**阶段:项目初始化 - 尚未开始开发**
本项目目前处于初始化阶段,主要完成了以下准备工作:
- ✅ BugPack 工具部署(用于 Bug 管理)
- ✅ MSYS2 环境配置
- ✅ Node.js 环境配置
- ⬜ 后端 SDK API 测试用例编写
- ⬜ 微信小程序测试用例编写
- ⬜ Web 管理后台测试用例编写
## 开发里程碑
| 里程碑 | 阶段 | 状态 | 计划完成时间 | 实际完成时间 | 备注 |
|--------|------|------|-------------|-------------|------|
| M1 | 项目初始化与环境搭建 | 🟢 已完成 | 2026-05-01 | 2026-05-01 | BugPack部署、MSYS2配置 |
| M2 | 后端 SDK API 测试框架搭建 | ⬜ 未开始 | - | - | 等待 API 文档 |
| M3 | 后端 SDK API 测试用例编写 | ⬜ 未开始 | - | - | 等待 API 文档 |
| M4 | 微信小程序测试用例编写 | ⬜ 未开始 | - | - | 等待交互设计文档 |
| M5 | Web 管理后台自动化测试 | ⬜ 未开始 | - | - | 等待交互设计文档 |
| M6 | 测试执行与 Bug 上报 | ⬜ 未开始 | - | - | 依赖 M2-M5 |
| M7 | 测试报告生成与归档 | ⬜ 未开始 | - | - | 项目收尾 |
## 技术栈
| 类别 | 技术 |
|------|------|
| Bug 管理 | BugPack (React + Express + SQLite) |
| 测试框架 | Playwright (Web 自动化测试) |
| 脚本环境 | MSYS2 + Node.js |
| 包管理 | npm |
## 强制要求
1. **上下文加载**:每次会话开始时,必须在上下文中加载 `project.md` 文件,了解项目当前状态和里程碑进度。
2. **意图确认**:每次接收到任务后,必须仔细分析用户意图。如有不理解或认为意图模糊时,**必须反问用户**,确认好意图后再执行。
3. **Agent 协同**:执行任务时,应在当前合适的 Agent 配置中选取 Agent 调用。如果能同时协同多个 Agent,则协同调用多个 Agent 完成任务,提升任务效率。
4. **Bug 管理****强制使用 BugPack MCP 服务**来维护 BUG 信息。所有发现的 Bug 必须通过 BugPack 记录和跟踪。
## 核心原则
> **你只负责根据用户提供的 API 文档、交互设计文档,编写测试用例,不涉及任何问题分析和业务代码修改。**
### 测试范围界定
| 测试类型 | 职责 | 输出物 |
|---------|------|--------|
| **后端 SDK API 测试** | 编写测试用例及相关测试代码,完成测试并上报 BUG | 测试代码 + BugPack Bug 记录 |
| **微信小程序黑盒测试** | 根据交互文档编写测试用例,告知用户执行即可 | 测试用例文档 |
| **Web 端管理后台测试** | 使用 Playwright MCP 服务自主进行测试,完成 Bug 上报 | 自动化测试脚本 + BugPack Bug 记录 |
### 禁止事项
- ❌ 不允许修改业务代码
- ❌ 不允许进行问题根因分析(只记录现象)
- ❌ 不允许超出测试用例范围的代码编写
- ❌ 不允许手动执行微信小程序测试(仅提供用例)
## BugPack 服务信息
- **服务地址**http://localhost:3459
- **项目名**QuanYiXiaoZhuShou
- **启动方式**:双击 `BugPack-main/start-bugpack.bat`
## 待办事项
- [ ] 获取后端 SDK API 文档
- [ ] 获取微信小程序交互设计文档
- [ ] 获取 Web 管理后台交互设计文档
- [ ] 搭建 API 测试框架
- [ ] 编写第一批测试用例
View File