diff --git a/PUSH.sh b/PUSH.sh new file mode 100755 index 0000000..2b3dfcb --- /dev/null +++ b/PUSH.sh @@ -0,0 +1,16 @@ +#!/bin/bash +echo "🚀 Pushing to Gitea..." +echo "" +echo "Make sure you've created the repository on Gitea:" +echo "https://gitea.pipexerp.com/huangjun/claude-marketplace" +echo "" +read -p "Press Enter to continue..." + +git push -u origin main + +echo "" +echo "✅ Done! Your marketplace is now live at:" +echo "https://gitea.pipexerp.com/huangjun/claude-marketplace" +echo "" +echo "Test it with:" +echo "/plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git" diff --git a/README.md b/README.md index e69de29..a661794 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,179 @@ +# Claude Code Plugin Marketplace + +Custom Claude Code plugins for development workflows, DevOps, and business operations. + +## 🚀 Quick Start + +### Add the Marketplace + +```bash +/plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git +``` + +Or using HTTPS (requires credential configuration): +```bash +/plugin marketplace add https://gitea.pipexerp.com/huangjun/claude-marketplace.git +``` + +### Install Plugins + +```bash +# Install specific plugin +/plugin install ai-proj-plugin@coolbuy-claude-plugins + +# List all available plugins +/plugin marketplace list coolbuy-claude-plugins +``` + +## 📦 Available Plugins (40 Total) + +### 🚀 Development & Coding +- **ai-proj-plugin** - AI project management with MCP integration +- **dev-plugin** - General development workflows +- **dev-arch-plugin** - Software architecture and design +- **dev-coding-plugin** - Coding standards and best practices +- **dev-test-plugin** - Testing workflows and automation +- **frontend-design-plugin** - Frontend design and UI/UX + +### 🔧 DevOps & Operations +- **ops-tools-plugin** - DevOps tools for deployment and monitoring +- **ops-servers-plugin** - Server management and SSH operations +- **enjoysa-deploy-plugin** - Enjoysa deployment automation +- **req-deploy-plugin** - Requirement deployment workflows + +### 📋 Project & Requirements Management +- **req-plugin** - Requirement management workflows +- **req-dev-plugin** - Requirement development processes +- **req-prd-plugin** - PRD (Product Requirements Document) creation +- **req-commands-plugin** - Detailed command reference for /req +- **req-review-plugin** - PRD review methodology +- **req-workflow-plugin** - Complete requirement lifecycle workflow +- **requirement-plugin** - General requirement handling +- **executing-plans-plugin** - Plan execution and tracking + +### 💼 Business Operations +- **biz-plan-plugin** - Business planning and proposals +- **biz-contract-plugin** - Contract drafting and management +- **finance-plugin** - Financial operations and reporting + +### 🔗 Integrations +- **feishu-plugin** - Feishu/Lark integration (documents, spreadsheets) +- **feishu-bitable-plugin** - Feishu Bitable (multi-dimensional tables) +- **feishu-docx-plugin** - Feishu cloud documents +- **wecom-plugin** - WeChat Work integration +- **siyuan-plugin** - SiYuan note-taking integration +- **data-excel-plugin** - Excel data processing +- **doubao-voice-plugin** - Doubao Voice API (TTS, ASR, dialogue) + +### 🏢 Project-Specific +- **coolbuy-legacy-plugin** - Coolbuy legacy system workflows +- **coolbuy-paas-plugin** - Coolbuy PaaS platform operations +- **coolbuy-platform-plugin** - Coolbuy platform development +- **enjoysa-plugin** - Enjoysa project workflows + +### 🔄 Session Management +- **save-session-plugin** - Save Claude Code sessions +- **reload-session-plugin** - Reload saved sessions +- **read-session-plugin** - Read session data +- **search-sessions-plugin** - Search through sessions + +### 🛠️ Utilities +- **pr-plugin** - Pull request creation and management +- **finishing-a-development-branch-plugin** - Branch finishing workflows +- **skill-manager-plugin** - Skill management utilities +- **qiudl-personal-plugin** - Personal productivity tools + +## 📖 Usage Examples + +### AI Project Management +```bash +/ai-proj-plugin:create-task "Implement user authentication" +/ai-proj-plugin:start-timer +``` + +### DevOps Operations +```bash +/ops-tools-plugin:deploy production +/ops-servers-plugin:check-status +``` + +### Development Workflow +```bash +/dev-coding-plugin:start-feature "user-profile" +/finishing-a-development-branch-plugin:create-pr +``` + +### Business Operations +```bash +/biz-contract-plugin:draft "Software Subscription Agreement" +/biz-plan-plugin:create-proposal +``` + +## 🔐 Private Repository Access + +For background auto-updates, set authentication token: + +```bash +# Add to ~/.zshrc or ~/.bashrc +export GITEA_TOKEN="your-gitea-token" +``` + +## 📝 Plugin Naming Convention + +All plugins use namespaced commands to avoid conflicts: +- Format: `/plugin-name:command-name` +- Example: `/ai-proj-plugin:create-task` + +## 🔄 Updates + +Update the marketplace and plugins: + +```bash +# Update marketplace catalog +/plugin marketplace update coolbuy-claude-plugins + +# Update specific plugin +/plugin update ai-proj-plugin@coolbuy-claude-plugins + +# Update all plugins from this marketplace +/plugin update-all +``` + +## 🏗️ Plugin Structure + +Each plugin follows the official Claude Code plugin structure: +``` +plugin-name-plugin/ +├── .claude-plugin/ +│ └── plugin.json # Plugin metadata +├── skills/ +│ └── SKILL.md # Agent skills +└── [scripts, configs, etc.] +``` + +## 📚 Documentation + +- [Official Claude Code Plugin Docs](https://code.claude.com/docs/en/plugins) +- [Plugin Marketplace Guide](https://code.claude.com/docs/en/plugin-marketplaces) + +## 👤 Author + +**Donglin Lai (qiudl)** +- Email: qiudl@zhiyuncai.com +- Organization: PipeX ERP + +## 📄 License + +Individual plugins may have their own licenses. Please check each plugin's documentation. + +## 🤝 Contributing + +To add or update plugins: +1. Clone this repository +2. Add/modify plugins in the `plugins/` directory +3. Regenerate `marketplace.json` using the provided scripts +4. Submit changes + +--- + +**Repository**: git@gitea.pipexerp.com:huangjun/claude-marketplace.git diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..fd62779 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,129 @@ +# Setup Guide + +## 1. Create Repository on Gitea + +Go to https://gitea.pipexerp.com and create a new repository: +- Name: `claude-marketplace` +- Visibility: Private or Public (your choice) +- **Do NOT** initialize with README (we already have one) + +## 2. Push to Gitea + +```bash +cd /Users/junhuang/coolbuy/claude-marketplace +git push -u origin main +``` + +## 3. Test Installation + +### Add the marketplace +```bash +# SSH (recommended) +/plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git + +# OR HTTPS (requires credential configuration) +/plugin marketplace add https://gitea.pipexerp.com/huangjun/claude-marketplace.git +``` + +### List available plugins +```bash +/plugin marketplace list coolbuy-claude-plugins +``` + +### Install a plugin +```bash +/plugin install ai-proj-plugin@coolbuy-claude-plugins +``` + +### Test the plugin +```bash +# Skills are auto-invoked by Claude when relevant, or use: +/help +# Check for your installed plugins +``` + +## 4. Update Plugins Later + +When you make changes and push updates: + +```bash +cd /Users/junhuang/coolbuy/claude-marketplace + +# Make changes to plugins +# ... + +# Regenerate marketplace.json if needed +python3 generate-marketplace.py + +# Commit and push +git add . +git commit -m "Update plugins" +git push +``` + +Users update with: +```bash +/plugin marketplace update coolbuy-claude-plugins +/plugin update ai-proj-plugin@coolbuy-claude-plugins +``` + +## 5. Private Repository Setup + +If your Gitea repo is private, users need authentication: + +**For manual operations** (install, update): +- SSH: Configure SSH keys in Gitea +- HTTPS: Will prompt for credentials + +**For background auto-updates**: +```bash +# Add to ~/.zshrc or ~/.bashrc +export GITEA_TOKEN="your-gitea-personal-access-token" +``` + +To create a Gitea token: +1. Go to https://gitea.pipexerp.com/user/settings/applications +2. Generate New Token +3. Give it "Read repository" permissions +4. Copy the token and add to your environment + +## 6. Structure Overview + +``` +claude-marketplace/ +├── .claude-plugin/ +│ └── marketplace.json # Catalog of all plugins +├── plugins/ +│ ├── ai-proj-plugin/ +│ │ ├── .claude-plugin/ +│ │ │ └── plugin.json # Plugin metadata +│ │ └── skills/ +│ │ └── SKILL.md # Skill definition +│ └── [33 more plugins...] +├── README.md # User documentation +├── SETUP.md # This file +└── convert-skills.sh # Conversion script (reference) +``` + +## Next Steps + +1. ✅ Push to Gitea: `git push -u origin main` +2. ✅ Test locally: `/plugin marketplace add ` +3. ✅ Install plugins: `/plugin install @coolbuy-claude-plugins` +4. ✅ Share with team: Send them the repository URL + +## Troubleshooting + +**"Failed to clone repository"** +- Check SSH key configuration: `ssh -T git@gitea.pipexerp.com -p 10022` +- Or use HTTPS with credentials + +**"Plugin not found"** +- Verify marketplace added: `/plugin marketplace list` +- Check plugin name is correct +- Ensure marketplace.json is valid: `cat .claude-plugin/marketplace.json | jq` + +**"Skills not working"** +- Skills are Agent Skills (auto-invoked by Claude when relevant) +- They don't create slash commands +- Check plugin installation: `/plugin list` diff --git a/SYNC-GUIDE.md b/SYNC-GUIDE.md new file mode 100644 index 0000000..79284ae --- /dev/null +++ b/SYNC-GUIDE.md @@ -0,0 +1,218 @@ +# Skill Sync Guide + +## Overview + +This guide explains how to keep your local skills (`~/.claude/skills/`) synchronized with the marketplace plugins. + +## Quick Sync + +```bash +cd /path/to/claude-marketplace +./sync-skills.sh +``` + +This will: +1. ✅ Compare local skills with marketplace plugins +2. ➕ Add new skills as plugins +3. 📝 Update changed skills +4. ✓ Skip unchanged plugins + +## Sync Workflow + +### 1. Edit Skills Locally + +Work on your skills in `~/.claude/skills/`: +```bash +code ~/.claude/skills/my-skill/SKILL.md +``` + +### 2. Run Sync Script + +```bash +cd ~/path/to/claude-marketplace +./sync-skills.sh +``` + +### 3. Review Changes + +```bash +git status +git diff +``` + +### 4. Commit & Push + +```bash +git add . +git commit -m "Update skill: description of changes" +git push +``` + +### 5. Team Updates + +Team members update with: +```bash +/plugin marketplace update coolbuy-claude-plugins +/plugin update @coolbuy-claude-plugins +``` + +## Automated Sync (Optional) + +### Git Hook (Pre-commit) + +Auto-sync when committing changes to skills: + +```bash +# In your dotfiles/skills repo +cat > .git/hooks/pre-commit << 'EOF' +#!/bin/bash +# Auto-sync skills to marketplace +~/path/to/claude-marketplace/sync-skills.sh +EOF + +chmod +x .git/hooks/pre-commit +``` + +### Cron Job (Scheduled) + +Sync daily at 9 AM: + +```bash +crontab -e + +# Add this line: +0 9 * * * cd ~/path/to/claude-marketplace && ./sync-skills.sh && git add . && git commit -m "Daily sync" && git push +``` + +## Skill Splitting Guidelines + +From `~/.claude/CLAUDE.md`: + +- **Token Limit**: Single skill ≤ 10,000 tokens +- **Check Size**: `wc -w ~/.claude/skills//SKILL.md` +- **When to Split**: If > 7,500 words (≈10,000 tokens) + +### Split Strategy + +When a skill grows too large: + +1. **Entry Skill** - Overview + command routing (<100 lines) + - Example: `req/SKILL.md` + +2. **Command Reference** - Detailed commands (<200 lines) + - Example: `req-commands/SKILL.md` + +3. **Workflow Guide** - Complete processes (<200 lines) + - Example: `req-workflow/SKILL.md` + +4. **Methodology** - Complex concepts (<150 lines) + - Example: `req-review/SKILL.md` + +## Troubleshooting + +### Sync Script Fails + +```bash +# Check permissions +ls -la sync-skills.sh + +# Make executable +chmod +x sync-skills.sh + +# Check paths +echo $HOME/.claude/skills +``` + +### marketplace.json Not Updated + +```bash +# Manually regenerate +python3 generate-marketplace.py + +# Or edit directly +code .claude-plugin/marketplace.json +``` + +### Git Conflicts + +```bash +# Discard local changes +git checkout .claude-plugin/marketplace.json + +# Or merge manually +git mergetool +``` + +## Best Practices + +### 1. Descriptive Frontmatter + +Always include in `SKILL.md`: +```yaml +--- +name: skill-name +description: Clear, concise description of what this skill does +--- +``` + +### 2. Version Bumping + +When making significant changes: +```bash +# Update version in plugin.json +{ + "version": "1.1.0" # was 1.0.0 +} +``` + +### 3. Testing Before Sync + +```bash +# Test skill locally first +/skill-name + +# Then sync to marketplace +./sync-skills.sh +``` + +### 4. Commit Messages + +Use clear, descriptive messages: +```bash +git commit -m "Add feishu-bitable plugin for table operations" +git commit -m "Update req-workflow with new approval process" +git commit -m "Fix: Correct PRD template in req-prd" +``` + +## Monitoring + +### Check Sync Status + +```bash +# Compare local vs marketplace +diff -qr ~/.claude/skills /tmp/claude-marketplace/plugins +``` + +### List Differences + +```bash +# Find skills not in marketplace +comm -23 <(ls ~/.claude/skills | sort) <(ls plugins | sed 's/-plugin$//' | sort) + +# Find plugins not in local +comm -13 <(ls ~/.claude/skills | sort) <(ls plugins | sed 's/-plugin$//' | sort) +``` + +## FAQ + +**Q: Can I sync in reverse (marketplace → local)?** +A: Not recommended. Treat local skills as the source of truth. + +**Q: What about binary files (images, scripts)?** +A: Copy them manually to the plugin directory, then commit. + +**Q: How do I remove a plugin?** +A: Delete the plugin directory, regenerate marketplace.json, commit, and push. + +**Q: Can I sync specific skills only?** +A: Modify `sync-skills.sh` to accept a skill name parameter. diff --git a/convert-skills.sh b/convert-skills.sh new file mode 100755 index 0000000..5f21b19 --- /dev/null +++ b/convert-skills.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Convert dotfiles custom skills to official Claude Code plugins + +SOURCE_DIR="/Users/junhuang/coolbuy/new-ai-proj/dotfiles/claude-skills" +TARGET_DIR="/Users/junhuang/coolbuy/claude-marketplace/plugins" + +echo "Converting skills to plugins..." + +# Loop through each skill directory +for skill_dir in "$SOURCE_DIR"/*; do + # Skip non-directories and special files + if [ ! -d "$skill_dir" ] || [ "$(basename "$skill_dir")" = ".git" ]; then + continue + fi + + skill_name=$(basename "$skill_dir") + plugin_name="${skill_name}-plugin" + plugin_dir="$TARGET_DIR/$plugin_name" + + echo "Processing: $skill_name -> $plugin_name" + + # Create plugin directory structure + mkdir -p "$plugin_dir/.claude-plugin" + mkdir -p "$plugin_dir/skills" + + # Copy SKILL.md if exists + if [ -f "$skill_dir/SKILL.md" ]; then + cp "$skill_dir/SKILL.md" "$plugin_dir/skills/" + fi + + # Copy skill.yaml if exists (for reference) + if [ -f "$skill_dir/skill.yaml" ]; then + cp "$skill_dir/skill.yaml" "$plugin_dir/.skill.yaml.original" + fi + + # Copy any Python scripts or other files + find "$skill_dir" -type f \( -name "*.py" -o -name "*.sh" -o -name "*.js" -o -name "*.json" -o -name "*.md" \) -not -name "SKILL.md" -not -name "skill.yaml" -exec cp {} "$plugin_dir/" \; + + # Copy subdirectories (like scripts/) + for subdir in "$skill_dir"/*; do + if [ -d "$subdir" ]; then + subdir_name=$(basename "$subdir") + cp -r "$subdir" "$plugin_dir/" + fi + done + + # Read version from skill.yaml if exists, otherwise use 1.0.0 + version="1.0.0" + if [ -f "$skill_dir/skill.yaml" ]; then + yaml_version=$(grep "^version:" "$skill_dir/skill.yaml" | sed 's/version: *//' | tr -d '"' | tr -d "'") + if [ ! -z "$yaml_version" ]; then + version="$yaml_version" + fi + fi + + # Read description from skill.yaml + description="Plugin for $skill_name" + if [ -f "$skill_dir/skill.yaml" ]; then + yaml_desc=$(grep "^description:" "$skill_dir/skill.yaml" | sed 's/description: *//' | tr -d '"') + if [ ! -z "$yaml_desc" ]; then + description="$yaml_desc" + fi + fi + + # Create plugin.json + cat > "$plugin_dir/.claude-plugin/plugin.json" << EOF +{ + "name": "$plugin_name", + "description": "$description", + "version": "$version", + "author": { + "name": "qiudl" + } +} +EOF + + echo " ✓ Created $plugin_name" +done + +echo "" +echo "Conversion complete! Created $(ls -1 "$TARGET_DIR" | wc -l) plugins." diff --git a/generate-marketplace.py b/generate-marketplace.py new file mode 100644 index 0000000..6ab907b --- /dev/null +++ b/generate-marketplace.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path + +# Paths +script_dir = Path(__file__).parent.resolve() +plugins_dir = script_dir / "plugins" +marketplace_file = script_dir / ".claude-plugin" / "marketplace.json" + +# Category mapping +def get_category_and_keywords(plugin_name): + if any(x in plugin_name for x in ['dev-', 'coding', 'frontend']): + return "development", ["development", "coding", "workflow"] + elif any(x in plugin_name for x in ['ops-', 'deploy', 'server']): + return "devops", ["devops", "deployment", "operations"] + elif any(x in plugin_name for x in ['ai-proj', 'req']): + return "productivity", ["project-management", "tasks", "requirements"] + elif any(x in plugin_name for x in ['feishu', 'wecom', 'siyuan']): + return "integration", ["integration", "automation", "productivity"] + elif 'biz-' in plugin_name: + return "business", ["business", "planning", "contracts"] + elif 'session' in plugin_name: + return "workflow", ["session", "workflow", "productivity"] + else: + return "utility", ["utility", "tools"] + +# Collect plugins +plugins = [] +for plugin_dir in sorted(plugins_dir.glob("*-plugin")): + if not plugin_dir.is_dir(): + continue + + manifest_path = plugin_dir / ".claude-plugin" / "plugin.json" + if not manifest_path.exists(): + continue + + with open(manifest_path) as f: + manifest = json.load(f) + + plugin_name = plugin_dir.name + category, keywords = get_category_and_keywords(plugin_name) + + plugins.append({ + "name": plugin_name, + "source": f"./plugins/{plugin_name}", + "description": manifest.get("description", f"Plugin for {plugin_name}"), + "version": manifest.get("version", "1.0.0"), + "category": category, + "keywords": keywords, + "strict": False + }) + +# Create marketplace +marketplace = { + "name": "coolbuy-claude-plugins", + "owner": { + "name": "Donglin Lai (qiudl)", + "email": "qiudl@zhiyuncai.com" + }, + "metadata": { + "description": "Custom Claude Code plugins for development workflows, DevOps, and business operations", + "version": "1.0.0", + "pluginRoot": "./plugins" + }, + "plugins": plugins +} + +# Write marketplace.json +with open(marketplace_file, 'w') as f: + json.dump(marketplace, f, indent=2, ensure_ascii=False) + +print(f"✓ Generated marketplace.json with {len(plugins)} plugins") diff --git a/generate-marketplace.sh b/generate-marketplace.sh new file mode 100755 index 0000000..71bba0d --- /dev/null +++ b/generate-marketplace.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# Generate marketplace.json from converted plugins + +TARGET_DIR="/Users/junhuang/coolbuy/claude-marketplace/plugins" +MARKETPLACE_FILE="/Users/junhuang/coolbuy/claude-marketplace/.claude-plugin/marketplace.json" + +echo "Generating marketplace.json..." + +# Start JSON +cat > "$MARKETPLACE_FILE" << 'EOF' +{ + "name": "coolbuy-claude-plugins", + "owner": { + "name": "Donglin Lai (qiudl)", + "email": "qiudl@zhiyuncai.com" + }, + "metadata": { + "description": "Custom Claude Code plugins for development workflows, DevOps, and business operations", + "version": "1.0.0", + "pluginRoot": "./plugins" + }, + "plugins": [ +EOF + +# Loop through each plugin and extract info +first=true +for plugin_dir in "$TARGET_DIR"/*-plugin; do + if [ ! -d "$plugin_dir" ]; then + continue + fi + + plugin_name=$(basename "$plugin_dir") + manifest="$plugin_dir/.claude-plugin/plugin.json" + + if [ ! -f "$manifest" ]; then + continue + fi + + # Extract info from plugin.json using grep and sed + description=$(grep '"description"' "$manifest" | head -1 | sed 's/.*"description": *"\([^"]*\)".*/\1/') + version=$(grep '"version"' "$manifest" | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/') + + # Determine category based on plugin name + category="utility" + keywords="" + + case "$plugin_name" in + *dev-*|*coding*|*frontend*) + category="development" + keywords='["development", "coding", "workflow"]' + ;; + *ops-*|*deploy*|*server*) + category="devops" + keywords='["devops", "deployment", "operations"]' + ;; + *ai-proj*|*req*) + category="productivity" + keywords='["project-management", "tasks", "requirements"]' + ;; + *feishu*|*wecom*|*siyuan*) + category="integration" + keywords='["integration", "automation", "productivity"]' + ;; + *biz-*) + category="business" + keywords='["business", "planning", "contracts"]' + ;; + *session*) + category="workflow" + keywords='["session", "workflow", "productivity"]' + ;; + esac + + # Add comma if not first entry + if [ "$first" = true ]; then + first=false + else + echo "," >> "$MARKETPLACE_FILE" + fi + + # Write plugin entry + cat >> "$MARKETPLACE_FILE" << ENTRY + { + "name": "$plugin_name", + "source": "$plugin_name", + "description": "$description", + "version": "$version", + "category": "$category", + "keywords": $keywords, + "strict": false + }ENTRY +done + +# Close JSON +cat >> "$MARKETPLACE_FILE" << 'EOF' + + ] +} +EOF + +echo "✓ Generated marketplace.json with $(grep -c '"name"' "$MARKETPLACE_FILE" | awk '{print $1-1}') plugins" diff --git a/plugins/agent-swarm-plugin/.claude-plugin/plugin.json b/plugins/agent-swarm-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..9eff51f --- /dev/null +++ b/plugins/agent-swarm-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "agent-swarm-plugin", + "description": "Multi-agent orchestration using OpenAI Swarm patterns. Coordinate specialized agents for complex development workflows with handoffs and context sharing.", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/agent-swarm-plugin/skills/SKILL.md b/plugins/agent-swarm-plugin/skills/SKILL.md new file mode 100644 index 0000000..52c8f61 --- /dev/null +++ b/plugins/agent-swarm-plugin/skills/SKILL.md @@ -0,0 +1,406 @@ +--- +name: agent-swarm +description: Multi-agent orchestration using OpenAI Swarm patterns. Coordinate specialized agents for complex development workflows with handoffs and context sharing. +--- + +# Agent Swarm - Multi-Agent Orchestration + +基于 OpenAI Swarm 设计模式的多智能体协作系统,用于复杂开发任务的智能分解与协调。 + +## 核心概念 + +### 1. Agent(智能体) +每个 Agent 是具有特定职责的专家: +- **Instructions**: Agent 的角色定义和行为准则 +- **Functions**: Agent 可以调用的工具函数 +- **Handoffs**: 何时移交给其他 Agent + +### 2. Handoff(任务移交) +Agent 之间的控制权转移机制: +- 当前 Agent 完成自己的职责 +- 识别需要其他专长 +- 移交给最合适的 Agent + +### 3. Context Variables(上下文变量) +跨 Agent 共享的状态: +- 项目目录 +- 技术栈信息 +- 当前进度 +- 发现的问题 + +--- + +## 预定义 Agent + +### 1. Architect Agent(架构师) +**职责**: 理解需求、技术选型、设计系统架构 + +**何时使用**: +- 用户描述新功能或系统 +- 需要技术方案设计 +- 需要架构评审 + +**工具**: +- Read codebase +- Grep patterns +- 设计文档生成 + +**Handoff to**: +- Coder Agent(开始编码) +- Reviewer Agent(评审设计) + +--- + +### 2. Coder Agent(编码者) +**职责**: 实现功能、编写代码、修复 bug + +**何时使用**: +- 架构师完成设计 +- 用户提出 bug 修复 +- 需要代码重构 + +**工具**: +- Edit files +- Write files +- Git operations + +**Handoff to**: +- Tester Agent(代码完成后) +- Architect Agent(遇到设计问题) + +--- + +### 3. Tester Agent(测试员) +**职责**: 编写测试、运行测试、验证功能 + +**何时使用**: +- 代码编写完成 +- 需要测试覆盖 +- 验证 bug 修复 + +**工具**: +- Run tests +- Write test cases +- Coverage reports + +**Handoff to**: +- Deployer Agent(测试通过) +- Coder Agent(发现问题) + +--- + +### 4. Deployer Agent(部署员) +**职责**: 构建镜像、部署服务、监控上线 + +**何时使用**: +- 测试全部通过 +- 需要发布到环境 +- 需要回滚版本 + +**工具**: +- Docker build +- SSH deployment +- Health checks + +**Handoff to**: +- Monitor Agent(部署完成) +- Coder Agent(部署失败) + +--- + +### 5. Reviewer Agent(评审员) +**职责**: 代码审查、文档审查、安全检查 + +**何时使用**: +- PR 创建后 +- 重要功能完成 +- 需要质量把关 + +**工具**: +- Diff analysis +- Security scan +- Best practices check + +**Handoff to**: +- Coder Agent(需要修改) +- Deployer Agent(审查通过) + +--- + +## 使用方法 + +### 基本调用 + +```bash +/swarm start "在 new-ai-proj 中实现任务批量删除功能" +``` + +**执行流程**: +1. **Architect** 分析需求 → 设计 API 和前端交互 +2. **Coder** 实现后端 API → 实现前端 UI +3. **Tester** 编写单元测试 → 运行测试 +4. **Reviewer** 代码审查 → 安全检查 +5. **Deployer** 部署到 staging → 验证功能 + +--- + +### 指定起始 Agent + +```bash +/swarm coder "修复 backend/handlers/task_handler.go 的空指针 bug" +``` + +直接从 Coder Agent 开始,跳过架构设计阶段。 + +--- + +### 传递上下文 + +```bash +/swarm start "优化数据库查询性能" \ + --context project=/Users/coolbuy-dev/coding/new-ai-proj \ + --context stack=Go,PostgreSQL,Redis \ + --context module=backend/services +``` + +--- + +### 查看执行轨迹 + +```bash +/swarm trace +``` + +显示 Agent 调用链: +``` +Architect → analyzed requirements (3 min) + ↓ handoff: "Design complete, ready for implementation" +Coder → implemented 5 files (12 min) + ↓ handoff: "Code complete, needs testing" +Tester → wrote 8 test cases, all passed (5 min) + ↓ handoff: "Tests passed, ready for review" +Reviewer → approved with 2 suggestions (2 min) + ↓ handoff: "Approved, ready for deployment" +Deployer → deployed to staging, health check OK (3 min) +``` + +--- + +## 配置文件 + +### swarm.yaml + +在项目根目录创建 `swarm.yaml` 自定义 Agent 行为: + +```yaml +agents: + architect: + instructions: | + 你是系统架构师,专注于 Go + Vue.js 技术栈。 + 遵循 RESTful API 设计原则。 + 考虑性能、安全性、可维护性。 + max_turns: 5 + + coder: + instructions: | + 你是 Go 后端工程师和 Vue.js 前端工程师。 + 编写清晰、简洁、高性能的代码。 + 遵循项目现有代码风格。 + tools: + - Edit + - Write + - Bash + max_turns: 10 + + tester: + instructions: | + 你是测试工程师,编写全面的测试用例。 + 确保边界条件、错误处理、并发安全。 + tools: + - Bash + - Write + test_command: "go test ./... -v" + max_turns: 5 + +context_variables: + project_root: /Users/coolbuy-dev/coding/new-ai-proj + backend_lang: Go 1.21 + frontend_framework: Vue 3 + database: PostgreSQL 15 + deployment_target: staging.ai.pipexerp.com +``` + +--- + +## 高级功能 + +### 1. 自定义 Agent + +```yaml +agents: + database-optimizer: + instructions: | + 你是数据库性能优化专家。 + 分析慢查询、优化索引、设计缓存策略。 + functions: + - explain_analyze + - create_index + - cache_design + handoff_to: + - coder # 实现优化方案 +``` + +--- + +### 2. 条件 Handoff + +```yaml +handoff_rules: + - from: tester + to: coder + condition: "test_pass_rate < 90%" + message: "测试失败率超过 10%,需要修复" + + - from: tester + to: deployer + condition: "test_pass_rate == 100%" + message: "所有测试通过,可以部署" +``` + +--- + +### 3. 并行 Agent + +对于独立任务,多个 Agent 可以并行工作: + +```bash +/swarm parallel \ + "coder: 实现后端 API" \ + "coder: 实现前端 UI" \ + "tester: 编写 API 测试" +``` + +--- + +## 与 Remote Coding 集成 + +在 OpenClaw 中调用本地 Claude Code 执行 Swarm 工作流: + +```bash +# OpenClaw 调用 Melbourne Claude Code +ssh melbourne "cd /Users/coolbuy-dev/coding/new-ai-proj && \ + /opt/homebrew/bin/claude --dangerously-skip-permissions \ + -p '/swarm start 实现任务批量删除功能'" +``` + +--- + +## 实际案例 + +### 案例 1: 新功能开发 + +**任务**: "为 AI-Proj 实现需求批量导出功能" + +**执行过程**: +1. **Architect**: + - 分析需求:导出格式(Excel/PDF)、筛选条件、数据脱敏 + - 设计 API: `POST /api/v1/requirements/export` + - 设计前端:导出按钮、进度条、下载链接 + +2. **Coder**: + - 后端实现 export service + - 前端实现导出 UI 组件 + - 集成 file download 功能 + +3. **Tester**: + - 测试大量数据导出(1000+ 需求) + - 测试并发导出 + - 测试下载失败重试 + +4. **Reviewer**: + - 检查文件大小限制 + - 检查内存泄漏风险 + - 检查数据权限控制 + +5. **Deployer**: + - 部署到 staging + - 验证导出功能 + - 监控资源使用 + +--- + +### 案例 2: Bug 修复 + +**任务**: "修复任务详情页加载缓慢问题" + +**执行过程**: +1. **Architect**: + - 分析性能瓶颈:N+1 查询问题 + - 设计优化方案:使用 JOIN 和预加载 + +2. **Coder**: + - 优化数据库查询 + - 添加 Redis 缓存 + - 更新前端数据获取逻辑 + +3. **Tester**: + - 性能测试:加载时间从 3s → 300ms + - 并发测试:100 用户同时访问 + - 缓存一致性测试 + +4. **Deployer**: + - 灰度发布到 10% 用户 + - 监控性能指标 + - 全量发布 + +--- + +## 最佳实践 + +1. **明确任务范围**: 复杂任务交给 Swarm,简单任务直接执行 +2. **合理设置 max_turns**: 避免 Agent 陷入死循环 +3. **记录 Handoff 原因**: 便于追溯和调试 +4. **定期审查轨迹**: 优化 Agent 协作流程 +5. **利用 Context Variables**: 避免重复传递信息 + +--- + +## 故障排查 + +| 问题 | 原因 | 解决方案 | +|------|------|----------| +| Agent 一直循环 | max_turns 设置过大 | 降低 max_turns,添加明确的 handoff 条件 | +| Handoff 失败 | 目标 Agent 未定义 | 检查 swarm.yaml 配置 | +| 上下文丢失 | Context Variables 未传递 | 在 handoff 时显式传递 context | +| 执行太慢 | 串行执行可并行任务 | 使用 `/swarm parallel` | + +--- + +## 与其他 Skills 集成 + +- **dev-coding**: Coder Agent 使用 dev-coding 的编码规范 +- **dev-test**: Tester Agent 使用 dev-test 的测试策略 +- **ops-tools**: Deployer Agent 使用 ops-tools 进行部署 +- **ai-proj**: 所有 Agent 使用 ai-proj MCP 进行任务同步 + +--- + +## 命令速查 + +| 命令 | 功能 | +|------|------| +| `/swarm start ` | 启动 Swarm 工作流(从 Architect 开始) | +| `/swarm ` | 从指定 Agent 开始 | +| `/swarm parallel ` | 并行执行多个任务 | +| `/swarm trace` | 查看执行轨迹 | +| `/swarm config` | 显示当前配置 | +| `/swarm agents` | 列出所有可用 Agent | +| `/swarm stop` | 终止当前 Swarm 执行 | + +--- + +## 参考资料 + +- [OpenAI Swarm 文档](https://github.com/openai/swarm) +- [Multi-Agent Systems 设计模式](https://arxiv.org/abs/2308.00352) +- [Claude Code Skills 文档](https://docs.anthropic.com/claude-code/skills) diff --git a/plugins/ai-proj-plugin/.claude-plugin/plugin.json b/plugins/ai-proj-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..a994a19 --- /dev/null +++ b/plugins/ai-proj-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "ai-proj-plugin", + "description": "AI project management via REST API. Works out of the box!", + "version": "2.0.1", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/ai-proj-plugin/README.md b/plugins/ai-proj-plugin/README.md new file mode 100644 index 0000000..f61fb24 --- /dev/null +++ b/plugins/ai-proj-plugin/README.md @@ -0,0 +1,136 @@ +# ai-proj Plugin + +AI project task and requirement management through direct REST API calls. + +## Features + +- **Task Management**: Create, list, update, and complete tasks +- **Requirement Management**: Full requirement lifecycle (draft → review → development → complete) +- **Documentation**: Create and manage task documents +- **Daily Focus**: Plan and view today's tasks +- **Direct API Integration**: Uses REST API directly + +## Quick Start + +Just install the plugin and start using it: + +```bash +/plugin install ai-proj-plugin@coolbuy-claude-plugins +``` + +That's it! No configuration needed. + +## Usage + +This plugin provides **Agent Skills** that I (Claude) automatically use when you mention relevant topics. + +### Examples + +**Task Management:** +- "Create a task: implement user authentication" +- "List all in-progress tasks" +- "Complete task 123" +- "Show me task 456" + +**Requirement Management:** +- "Create requirement: add dark mode support" +- "List requirements in draft status" +- "Submit requirement 789 for review" +- "Approve requirement 789" + +**Daily Focus:** +- "Show today's tasks" +- "Add task 123 to today's focus" + +**Documents:** +- "Show the document for task 123" +- "Update task 456's document with: [content]" + +## Configuration (Optional) + +The plugin uses a default API token that works for most users. If you need to use a custom token: + +### Environment Variable + +```bash +export AIPROJ_TOKEN="your_custom_token_here" +``` + +Add this to your `~/.zshrc` or `~/.bashrc` to make it persistent. + +## How It Works + +This plugin uses a simple, straightforward approach: + +1. Makes direct REST API calls to `https://ai.pipexerp.com/api/v1` +2. Uses `curl` for HTTP requests +3. Automatically finds the API token from: + - `$AIPROJ_TOKEN` environment variable + - Built-in default token + +## Advantages + +- ✅ **No setup required** - works immediately after installation +- ✅ **No server process** - simple and lightweight +- ✅ **No dependencies** - just needs `curl` (built into all systems) +- ✅ **Simple architecture** - direct API calls +- ✅ **Easy debugging** - can see exact API calls + +## API Endpoints + +The plugin uses these endpoints: + +- `GET /tasks` - List tasks +- `POST /tasks` - Create task +- `GET /tasks/{id}` - Get task details +- `PATCH /tasks/{id}` - Update task +- `DELETE /tasks/{id}` - Delete task +- `GET /requirements` - List requirements +- `POST /requirements` - Create requirement +- `POST /requirements/{id}/actions` - Update requirement status +- `GET /tasks/{id}/document` - Get task document +- `PUT /tasks/{id}/document` - Update task document +- `GET /daily-focus/tasks` - Get today's tasks +- `POST /daily-focus/tasks` - Add task to today + +## Troubleshooting + +### "Unauthorized" or API errors + +Check your token: +```bash +echo $AIPROJ_TOKEN +``` + +If empty, either set the environment variable or the plugin will use the default token. + +### API not responding + +Verify the API is accessible: +```bash +curl -I https://ai.pipexerp.com/api/v1/health +``` + +### Skills not working + +- Agent Skills don't create slash commands +- Just ask naturally: "create a task", "list tasks", etc. +- The plugin is automatically active when installed + +## Technical Details + +**API Base**: `https://ai.pipexerp.com/api/v1` +**Authentication**: Bearer token in `Authorization` header +**Data Format**: JSON +**Date Format**: ISO 8601 (YYYY-MM-DD) +**Default Project ID**: 1 + +## Documentation + +For detailed API documentation and examples, see `skills/SKILL.md`. + +## Support + +- Repository: git@gitea.pipexerp.com:huangjun/claude-marketplace.git +- Author: qiudl@zhiyuncai.com + diff --git a/plugins/ai-proj-plugin/skills/SKILL.md b/plugins/ai-proj-plugin/skills/SKILL.md new file mode 100644 index 0000000..88a061f --- /dev/null +++ b/plugins/ai-proj-plugin/skills/SKILL.md @@ -0,0 +1,1384 @@ +--- +name: ai-proj +description: ai-proj 任务与需求管理。用于创建/查询/更新需求、任务、文档。当用户提到 ai-proj、任务管理、需求管理、REQ-XXX 相关操作时自动激活。 +--- + +# ai-proj MCP 技能 + +通过 MCP (Model Context Protocol) 直接调用 ai-proj 服务,管理任务、需求和文档。 + +## 功能概述 + +| 模块 | 功能 | +|------|------| +| **任务管理** | 创建、查询、更新、删除任务,子任务管理 | +| **需求管理** | 需求全生命周期:草稿→评审→开发→完成 | +| **文档管理** | 任务文档的创建、编辑、同步 | +| **计时器** | 任务计时、时间追踪 | +| **每日聚焦** | 今日任务规划、智能推荐 | +| **工作笔记** | 知识管理、笔记搜索 | +| **远程同步** | 本地↔远程数据同步 | + +--- + +## 快速开始 + +### 常用命令速查 + +``` +# 任务操作 +"创建任务: xxx" → mcp__ai-proj__create_task +"查看任务列表" → mcp__ai-proj__list_tasks +"开始任务 123" → mcp__ai-proj__start_task +"完成任务 123" → mcp__ai-proj__complete_task +"查找任务 xxx" → mcp__ai-proj__find_task + +# 需求操作 +"创建需求: xxx" → mcp__ai-proj__create_requirement +"查看需求列表" → mcp__ai-proj__list_requirements +"提交需求 123 评审" → mcp__ai-proj__requirement_action (submit) +"批准需求 123" → mcp__ai-proj__requirement_action (approve) + +# 文档操作 +"为任务 123 创建文档" → mcp__ai-proj__create-and-attach +"查看任务 123 的文档" → mcp__ai-proj__get_task_document + +# 今日聚焦 +"查看今日任务" → mcp__ai-proj__get_daily_focus_tasks +"添加任务到今日聚焦" → mcp__ai-proj__add_daily_focus_task + +# 计时器 +"开始计时任务 123" → mcp__ai-proj__start_task_with_timer +"停止计时" → mcp__ai-proj__stop_timer +``` + +--- + +## 一、任务管理 + +### 1.1 创建任务 + +**MCP 函数**: `mcp__ai-proj__create_task` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| title | string | ✅ | 任务标题 | +| projectId | number | ❌ | 项目ID,默认为1 | + +**自然语言示例**: +- "创建任务: 实现用户登录功能" +- "新建一个任务叫做数据库优化" +- "在项目2中创建任务: API接口开发" + +**返回示例**: +```json +{ + "id": 5069, + "title": "实现用户登录功能", + "status": "todo", + "project_id": 1 +} +``` + +### 1.2 查看任务列表 + +**MCP 函数**: `mcp__ai-proj__list_tasks` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| projectId | number | ❌ | 项目ID筛选 | +| status | array | ❌ | 状态筛选: draft/planning/todo/in_progress/testing/completed/cancelled/on_hold/suspended/blocked/archived | +| priority | array | ❌ | 优先级筛选: low/medium/high | +| search | string | ❌ | 搜索关键词 | +| page | number | ❌ | 页码,从1开始 | +| limit | number | ❌ | 每页数量,默认20,最大100 | +| sort_by | string | ❌ | 排序字段: created_at/updated_at/due_date/priority/title | +| sort_order | string | ❌ | 排序方向: asc/desc | +| response_mode | string | ❌ | 响应模式: minimal/compact/full | + +**自然语言示例**: +- "查看所有任务" +- "列出进行中的任务" +- "查看高优先级任务" +- "搜索包含登录的任务" + +### 1.3 查找任务 + +**MCP 函数**: `mcp__ai-proj__find_task` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | number | ❌ | 任务ID(优先使用) | +| titlePattern | string | ❌ | 标题搜索关键词 | + +**自然语言示例**: +- "查找任务 5069" +- "搜索任务名包含登录的" + +### 1.4 获取任务详情 + +**MCP 函数**: `mcp__ai-proj__get_detailed_task_info` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| taskId | number | ✅ | 任务ID | + +**返回**: 包含父任务、同级任务、子任务的格式化信息 + +### 1.5 更新任务 + +**MCP 函数**: `mcp__ai-proj__update_task` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | number | ✅ | 任务ID | +| updates | object | ✅ | 更新字段 | + +**updates 支持的字段**: +- `title`: 新标题 +- `description`: 新描述 +- `status`: 新状态 (draft/planning/todo/in_progress/testing/completed/cancelled/on_hold/suspended/blocked/archived) +- `priority`: 新优先级 (low/medium/high) +- `due_date`: 截止日期 (ISO 8601) +- `assignee_id`: 负责人ID + +**自然语言示例**: +- "把任务 5069 的状态改为进行中" +- "更新任务 5069 的优先级为高" +- "修改任务 5069 的标题为: 新标题" + +### 1.6 任务状态操作 + +**开始任务**: `mcp__ai-proj__start_task` +``` +参数: id (number) - 任务ID +说明: 将任务状态改为 in_progress +``` + +**完成任务**: `mcp__ai-proj__complete_task` +``` +参数: id (number) - 任务ID +说明: 将任务状态改为 completed +``` + +**暂停任务**: `mcp__ai-proj__pause_task` +``` +参数: id (number) - 任务ID +说明: 将任务状态改为 on_hold +``` + +**删除任务**: `mcp__ai-proj__delete_task` +``` +参数: +- id (number) - 任务ID +- force (boolean) - 是否强制删除(包含子任务) +``` + +### 1.7 子任务管理 + +**创建子任务**: `mcp__ai-proj__create_subtask` +``` +参数: +- parentId (number) - 父任务ID +- title (string) - 子任务标题 +``` + +**创建兄弟任务**: `mcp__ai-proj__create_sibling_task` +``` +参数: +- siblingId (number) - 兄弟任务ID(参考任务) +- title (string) - 新任务标题 +- description (string) - 任务描述 +- priority (string) - 优先级: low/medium/high +- status (string) - 状态 +``` + +**获取子任务**: `mcp__ai-proj__get_task_children` +``` +参数: parentId (number) - 父任务ID +``` + +### 1.8 移动任务 + +**MCP 函数**: `mcp__ai-proj__move_task` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | number | ✅ | 任务ID | +| targetProjectId | number | ✅ | 目标项目ID | + +--- + +## 二、需求管理 + +### 2.1 创建需求 + +**MCP 函数**: `mcp__ai-proj__create_requirement` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| title | string | ✅ | 需求标题 | +| projectId | number | ✅ | 项目ID | +| description | string | ❌ | 需求描述 | +| priority | string | ❌ | 优先级: low/medium/high/urgent | +| category | string | ❌ | 类别: feature/bug/improvement/documentation/other | +| allowSelfApprove | boolean | ❌ | 允许自审批(自动审批时需设为 true) | + +**创建需求必填清单**: +- [ ] `title` - 需求标题 +- [ ] `projectId` - 所属项目(不要遗漏!) +- [ ] `priority` - 优先级 +- [ ] `description` - 需求描述 + +> **注意**: 后端已实现默认截止日期:未填 `due_date` 时按优先级自动设置(urgent +1天、high +3天、medium +7天、low +14天)。MCP `create_requirement` 不支持 `due_date` 参数,如需自定义截止日期,创建后通过 REST API 更新。 + +**自然语言示例**: +- "创建需求: 用户权限管理功能" +- "新建一个高优先级需求: 性能优化" + +### 2.2 查看需求列表 + +**MCP 函数**: `mcp__ai-proj__list_requirements` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| project_id | number | ❌ | 项目ID筛选 | +| status | array | ❌ | 状态筛选: draft/pending/reviewing/approved/rejected/archived | +| priority | array | ❌ | 优先级筛选 | +| category | array | ❌ | 类别筛选 | +| search | string | ❌ | 搜索关键词 | +| page | number | ❌ | 页码 | +| page_size | number | ❌ | 每页数量 | +| response_mode | string | ❌ | 响应模式: minimal/compact/full | + +### 2.3 获取需求详情 + +**MCP 函数**: `mcp__ai-proj__get_requirement` + +**参数**: `id` (number) - 需求ID + +### 2.4 更新需求 + +**MCP 函数**: `mcp__ai-proj__update_requirement` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | number | ✅ | 需求ID | +| updates | object | ✅ | 更新字段 | + +**updates 支持的字段**: title, description, priority, category, allow_self_approve + +> **已知限制**: MCP `update_requirement` 工具**不支持**更新 `project_id` 和 `due_date` 字段。 +> 如需更新这两个字段,必须直接调用后端 REST API: +> ```bash +> # Dev 环境 +> curl -s -X PUT "http://localhost:8080/api/v1/requirements/{id}" \ +> -H "Content-Type: application/json" \ +> -H "X-API-Key: {TASK_API_TOKEN}" \ +> -d '{"project_id": 1, "due_date": "2026-02-27T00:00:00Z"}' +> +> # 生产环境 +> curl -s -X PUT "https://ai.pipexerp.com/api/v1/requirements/{id}" \ +> -H "Content-Type: application/json" \ +> -H "X-API-Key: {SYNC_REMOTE_API_KEY}" \ +> -d '{"project_id": 1, "due_date": "2026-02-27T00:00:00Z"}' +> ``` +> 注意:`due_date` 必须使用 RFC3339 格式(如 `2026-02-27T00:00:00Z`),`YYYY-MM-DD` 格式会返回 BAD_REQUEST。 + +### 2.5 需求工作流 + +**MCP 函数**: `mcp__ai-proj__requirement_action` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | number | ✅ | 需求ID | +| action | string | ✅ | 操作类型: submit/approve/reject/withdraw/archive | +| reason | string | ❌ | 拒绝原因(reject时必填) | +| comment | string | ❌ | 批准意见 | + +**工作流状态图**: +``` +draft → [submit] → pending → [approve] → approved + → [reject] → rejected +pending → [withdraw] → draft +approved → [archive] → archived +``` + +**自然语言示例**: +- "提交需求 123 进行评审" +- "批准需求 123" +- "拒绝需求 123,原因:需求不完整" +- "撤回需求 123" +- "归档需求 123" + +### 2.6 需求-任务关联 + +**关联任务到需求**: `mcp__ai-proj__link_tasks_to_requirement` +``` +参数: +- requirementId (number) - 需求ID +- taskIds (array) - 任务ID列表 +- linkComment (string) - 关联备注 +``` + +**取消关联**: `mcp__ai-proj__unlink_task_from_requirement` +``` +参数: +- requirementId (number) - 需求ID +- taskId (number) - 任务ID +``` + +**获取需求关联的任务**: `mcp__ai-proj__get_requirement_tasks` +``` +参数: +- requirementId (number) - 需求ID +- page, page_size - 分页参数 +``` + +### 2.7 需求统计与历史 + +**获取统计**: `mcp__ai-proj__get_requirement_statistics` +``` +参数: id (number) - 需求ID +返回: 关联任务数、完成率等统计信息 +``` + +**获取历史**: `mcp__ai-proj__get_requirement_history` +``` +参数: id (number), page, page_size +返回: 需求操作历史记录 +``` + +--- + +## 三、文档管理 + +### 3.1 创建任务文档 + +**MCP 函数**: `mcp__ai-proj__create-and-attach` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| taskId | number | ✅ | 任务ID | +| content | string | ✅ | 文档内容(Markdown格式) | +| title | string | ❌ | 文档标题 | +| projectId | number | ❌ | 项目ID | + +**自然语言示例**: +- "为任务 5069 创建PRD文档" +- "给任务 5069 添加开发计划文档" + +### 3.2 追加文档内容 + +**MCP 函数**: `mcp__ai-proj__append-document-content` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| taskId | number | ✅ | 任务ID | +| documentId | number | ✅ | 文档ID | +| content | string | ✅ | 追加内容(Markdown) | +| projectId | number | ❌ | 项目ID | + +### 3.3 获取文档 + +**获取元数据**: `mcp__ai-proj__get_task_document_meta` +``` +参数: taskId (number) +返回: 文档元数据(不含完整内容,节省token) +``` + +**获取内容**: `mcp__ai-proj__get_task_document` +``` +参数: +- taskId (number) - 任务ID +- projectId (number) - 项目ID +- maxLength (number) - 最大返回字符数 +- offset (number) - 起始位置 +``` + +**检查是否有文档**: `mcp__ai-proj__has_task_document` +``` +参数: taskId (number), projectId (number) +返回: boolean +``` + +### 3.4 更新/删除文档 + +**更新文档**: `mcp__ai-proj__update_task_document` +``` +参数: +- taskId (number) - 任务ID +- content (string) - 新内容 +- title (string) - 新标题 +- projectId (number) +``` + +**删除文档**: `mcp__ai-proj__delete_task_document` +``` +参数: taskId (number), projectId (number) +``` + +### 3.5 文档同步 + +**导出到文件**: `mcp__ai-proj__export_task_document_to_file` +``` +参数: +- taskId (number) +- projectId (number) +- overwrite (boolean) - 是否覆盖,默认true +导出位置: dev-plans 目录 +``` + +**从文件导入**: `mcp__ai-proj__import_task_document_from_file` +``` +参数: +- taskId (number) +- projectId (number) +- fileName (string) - 文件名 +- updateIfNewer (boolean) - 仅当文件更新时导入 +- forceOverwrite (boolean) - 强制覆盖 +``` + +**批量同步**: `mcp__ai-proj__sync_task_documents` +``` +参数: +- taskIds (array) - 任务ID列表 +- direction (string) - 同步方向: export/import/both +- forceOverwrite (boolean) +- projectId (number) +``` + +--- + +## 四、工作笔记 + +### 4.1 创建工作笔记 + +**MCP 函数**: `mcp__ai-proj__create_work_note` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| title | string | ✅ | 笔记标题 | +| content | string | ✅ | 笔记内容(Markdown) | +| type | string | ❌ | 类型: markdown/text/html | +| tags | array | ❌ | 标签列表 | +| status | string | ❌ | 状态: draft/published/archived | +| visibility | string | ❌ | 可见性: private/team/public | + +**创建并关联到任务**: `mcp__ai-proj__create-and-attach-work-note` +``` +参数: +- taskId (number) - 任务ID +- content (string) - 笔记内容 +- title (string) - 标题 +``` + +### 4.2 查看/搜索笔记 + +**列出笔记**: `mcp__ai-proj__list_work_notes` +``` +参数: page, limit, status, type +``` + +**搜索笔记**: `mcp__ai-proj__search_work_notes` +``` +参数: +- query (string) - 搜索关键词 +- tags (array) - 标签筛选 +- limit (number) +``` + +**获取详情**: `mcp__ai-proj__get_work_note` +``` +参数: id (number) +``` + +### 4.3 更新笔记 + +**MCP 函数**: `mcp__ai-proj__update_work_note` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | number | ✅ | 笔记ID | +| updates | object | ✅ | 更新字段 | + +--- + +## 五、计时器 + +### 5.1 基本计时 + +**开始计时**: `mcp__ai-proj__start_timer` +``` +参数: +- taskId (number) - 任务ID +- description (string) - 计时描述 +``` + +**停止计时**: `mcp__ai-proj__stop_timer` +``` +参数: taskId (number) - 可选,不指定则停止所有计时 +``` + +**获取当前计时**: `mcp__ai-proj__get_current_timer` +``` +无参数 +返回: 当前正在进行的计时器信息 +``` + +**获取活跃计时器**: `mcp__ai-proj__get_active_timers` +``` +无参数 +返回: 所有 running/paused 状态的计时器 +``` + +### 5.2 智能计时 + +**启动任务并计时**: `mcp__ai-proj__start_task_with_timer` +``` +参数: +- taskIdOrTitle (number|string) - 任务ID或标题(支持模糊匹配) +- timerDescription (string) - 计时描述 +- projectId (number) +功能: 自动开始任务并启动计时器 +``` + +**切换任务**: `mcp__ai-proj__switch_to_task` +``` +参数: +- newTaskTitle (string) - 任务标题(支持模糊匹配) +- projectId (number) +功能: 停止当前计时,切换到新任务并开始计时 +``` + +**自然语言示例**: +- "开始任务 登录功能开发 并计时" +- "切换到 API开发 任务" +- "停止计时" + +--- + +## 六、每日聚焦 + +### 6.1 查看今日任务 + +**MCP 函数**: `mcp__ai-proj__get_daily_focus_tasks` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| date | string | ❌ | 日期 (YYYY-MM-DD) | +| status | string | ❌ | 状态筛选: active/completed/removed | +| priority | string | ❌ | 优先级筛选 | +| include_suggestions | boolean | ❌ | 是否包含智能推荐 | + +### 6.2 添加任务到今日聚焦 + +**单个添加**: `mcp__ai-proj__add_daily_focus_task` +``` +参数: +- task_id (number) - 任务ID +- priority (string) - 优先级: critical/high/medium/low +- estimated_duration_minutes (number) - 预估时长 +- notes (string) - 备注 +- focus_date (string) - 聚焦日期 +``` + +**批量添加**: `mcp__ai-proj__batch_add_daily_focus_tasks` +``` +参数: +- task_ids (array) - 任务ID列表 +- priority (string) - 统一优先级 +- notes (string) - 统一备注 +- focus_date (string) +``` + +**快速添加当前任务**: `mcp__ai-proj__quick_add_current_task` +``` +参数: +- priority (string) - 优先级,默认high +- notes (string) +功能: 将当前正在进行的任务添加到今日聚焦 +``` + +### 6.3 管理今日任务 + +**更新**: `mcp__ai-proj__update_daily_focus_task` +``` +参数: +- id (number) - Daily Focus任务ID +- priority, estimated_duration_minutes, notes +``` + +**移除**: `mcp__ai-proj__remove_daily_focus_task` +``` +参数: id (number) +``` + +**完成**: `mcp__ai-proj__complete_daily_focus_task` +``` +参数: id (number) +``` + +**聚焦并计时**: `mcp__ai-proj__focus_task_with_timer` +``` +参数: +- daily_focus_task_id (number) +- timer_description (string) +``` + +**清理已完成**: `mcp__ai-proj__clear_completed_tasks` +``` +参数: +- date (string) - 日期 +- confirm (boolean) - 必须设为true +``` + +### 6.4 统计与推荐 + +**获取统计**: `mcp__ai-proj__get_daily_focus_stats` +``` +参数: +- date (string) +- period (string) - 统计周期: daily/weekly/monthly +``` + +**获取任务推荐**: `mcp__ai-proj__get_task_recommendations` +``` +参数: +- date (string) +- limit (number) - 推荐数量 +- exclude_existing (boolean) - 排除已存在任务 +返回: 基于优先级、截止日期等的智能推荐 +``` + +--- + +## 七、项目管理 + +### 7.1 查看项目列表 + +**MCP 函数**: `mcp__ai-proj__list_projects` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| status | string | ❌ | 状态筛选: active/inactive/planning/on_hold/completed/cancelled/archived | +| search | string | ❌ | 搜索关键词 | +| page | number | ❌ | 页码 | +| pageSize | number | ❌ | 每页数量 | +| sortBy | string | ❌ | 排序字段: created_at/updated_at/name/priority | +| sortOrder | string | ❌ | 排序方向: asc/desc | + +### 7.2 创建项目 + +**MCP 函数**: `mcp__ai-proj__create_project` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | ✅ | 项目名称 | +| description | string | ❌ | 项目描述 | + +--- + +## 八、项目手册 + +### 8.1 查看手册列表 + +**MCP 函数**: `mcp__ai-proj__list_manuals` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| projectId | number | ✅ | 项目ID | +| status | string | ❌ | 状态筛选: draft/published/archived | +| parentId | number | ❌ | 父级手册ID(获取子级列表) | +| search | string | ❌ | 搜索关键词(标题或描述) | + +**自然语言示例**: +- "查看项目手册列表" +- "列出已发布的手册" +- "查看手册 5 的子章节" + +### 8.2 创建手册 + +**MCP 函数**: `mcp__ai-proj__create_manual` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| projectId | number | ✅ | 项目ID | +| title | string | ✅ | 手册标题 | +| content | string | ❌ | 手册内容(Markdown格式) | +| description | string | ❌ | 手册简介 | +| parentId | number | ❌ | 父级手册ID(用于创建子章节) | +| status | string | ❌ | 状态: draft/published,默认draft | + +**自然语言示例**: +- "创建项目手册: API 开发规范" +- "在手册 5 下创建子章节: 接口设计规范" +- "创建并发布手册: 前端开发规范" + +### 8.3 获取手册详情 + +**MCP 函数**: `mcp__ai-proj__get_manual` + +**参数**: `id` (number) - 手册ID + +**返回**: 完整的手册内容(Markdown格式) + +**自然语言示例**: +- "查看手册 5 的内容" +- "显示 API 开发规范手册" + +### 8.4 更新手册 + +**MCP 函数**: `mcp__ai-proj__update_manual` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | number | ✅ | 手册ID | +| title | string | ❌ | 新标题 | +| content | string | ❌ | 新内容(Markdown格式) | +| description | string | ❌ | 新简介 | +| status | string | ❌ | 新状态: draft/published/archived | + +**自然语言示例**: +- "更新手册 5 的内容" +- "发布手册 5" +- "归档手册 5" + +### 8.5 搜索手册 + +**MCP 函数**: `mcp__ai-proj__search_manuals` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| projectId | number | ✅ | 项目ID | +| query | string | ✅ | 搜索关键词 | + +**自然语言示例**: +- "搜索项目手册中包含 API 的文档" +- "查找数据库相关的手册" + +--- + +## 九、报告与时间线 + +### 9.1 获取日报 + +**MCP 函数**: `mcp__ai-proj__get_daily_work_report` + +**参数**: `projectId` (number) - 项目ID,默认为1 + +**返回**: 今日工作报告,包含完成任务、进行中任务、时间统计等 + +### 9.2 获取任务时间线 + +**MCP 函数**: `mcp__ai-proj__get_task_timeline` + +**参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| taskId | number | ✅ | 任务ID | +| projectId | number | ❌ | 项目ID | +| limit | number | ❌ | 记录数限制,默认20 | +| offset | number | ❌ | 偏移量 | + +--- + +## 十、批量文档操作 + +### 10.1 批量创建文档 + +**MCP 函数**: `mcp__ai-proj__create_batch_documents` + +**参数**: +```json +{ + "documents": [ + { + "title": "文档标题", + "content": "Markdown内容", + "taskId": 5069, + "attachToTask": true, + "type": "markdown", + "status": "draft", + "tags": ["tag1", "tag2"], + "relationType": "attachment" + } + ] +} +``` + +--- + +## 十一、远程同步 + +### 11.1 任务同步 + +**同步任务**: `mcp__ai-proj__sync_task` +``` +参数: +- taskId (number) +- direction (string) - to_remote/from_remote +- includeDocument (boolean) - 是否同步文档 +- includeChildren (boolean) - 是否同步子任务 +``` + +**批量同步到远程**: `mcp__ai-proj__batch_sync_tasks_to_remote` +``` +参数: +- taskIds (array) +- includeDocuments (boolean) +- includeChildren (boolean) +``` + +**获取同步状态**: `mcp__ai-proj__get_task_sync_status` +``` +参数: taskId (number) +``` + +**获取远程同步状态**: `mcp__ai-proj__get_remote_sync_status` +``` +参数: taskId (number) +返回: 比较本地和远程版本差异 +``` + +**通过UUID获取任务**: `mcp__ai-proj__get_task_by_uuid` +``` +参数: uuid (string) +用途: 跨库同步场景 +``` + +### 11.2 需求同步 + +**同步需求到远程**: `mcp__ai-proj__sync_requirement_to_remote` +``` +参数: +- requirementId (number) +- includeHistory (boolean) +- includeComments (boolean) +``` + +**从远程同步需求**: `mcp__ai-proj__sync_requirement_from_remote` +``` +参数: +- remoteRequirementId (number) +- includeHistory (boolean) +- includeComments (boolean) +``` + +**批量同步需求**: `mcp__ai-proj__batch_sync_requirements_to_remote` +``` +参数: +- requirementIds (array) +- includeComments (boolean) +``` + +**同步需求-任务关联**: `mcp__ai-proj__sync_requirement_task_links` +``` +参数: +- linkUUIDs (array) - 关联关系的UUID列表 +- forceUpdate (boolean) +``` + +**同步任务的需求关联**: `mcp__ai-proj__sync_task_links_to_remote` +``` +参数: taskId (number) +``` + +--- + +## 十二、开发工具 + +### 12.1 快速登录 + +**MCP 函数**: `mcp__ai-proj__dev_quick_login` + +**参数**: `username` (string) - 用户名,默认qiudl + +**说明**: 仅在开发环境(APP_ENV=development/dev)有效,自动获取JWT + +--- + +## 自然语言操作示例 + +### 任务管理 + +| 用户说 | 调用函数 | +|--------|----------| +| "创建一个任务叫做登录功能开发" | `create_task` | +| "查看所有进行中的任务" | `list_tasks` | +| "开始任务 5069" | `start_task` | +| "完成任务 5069" | `complete_task` | +| "暂停任务 5069" | `pause_task` | +| "把任务 5069 移到项目2" | `move_task` | +| "给任务 5069 创建子任务: 数据库设计" | `create_subtask` | + +### 需求管理 + +| 用户说 | 调用函数 | +|--------|----------| +| "创建需求: 用户权限管理" | `create_requirement` | +| "查看待审核的需求" | `list_requirements` | +| "提交需求 123 评审" | `requirement_action (submit)` | +| "批准需求 123" | `requirement_action (approve)` | +| "拒绝需求 123,原因是需求不完整" | `requirement_action (reject)` | +| "将任务 5069 关联到需求 123" | `link_tasks_to_requirement` | + +### 文档操作 + +| 用户说 | 调用函数 | +|--------|----------| +| "为任务 5069 创建PRD文档" | `create-and-attach` | +| "查看任务 5069 的文档" | `get_task_document` | +| "更新任务 5069 的文档" | `update_task_document` | +| "导出任务 5069 的文档到文件" | `export_task_document_to_file` | + +### 计时与聚焦 + +| 用户说 | 调用函数 | +|--------|----------| +| "开始计时任务 5069" | `start_task_with_timer` | +| "停止计时" | `stop_timer` | +| "查看今日任务" | `get_daily_focus_tasks` | +| "把任务 5069 添加到今日聚焦" | `add_daily_focus_task` | +| "生成今日工作报告" | `get_daily_work_report` | + +--- + +## 注意事项 + +1. **项目ID默认值**: 大多数函数的 `projectId` 默认为 1 +2. **响应模式**: 使用 `response_mode: minimal` 可减少返回数据量 +3. **分页**: 大量数据请使用分页参数 `page` 和 `limit` +4. **状态流转**: 任务和需求都有状态机,注意状态转换规则 +5. **同步方向**: 远程同步时注意 `to_remote` 和 `from_remote` 方向 +6. **计时器**: 同一时间只能有一个活跃计时器 + +--- + +## 用户管理 + +> **重要**: 创建数据库用户时必须使用 bcrypt **cost 12** 进行密码哈希。 + +详细的用户管理流程请参考: +- **ops-tools/SKILL.md** - "AI-Proj 用户管理" 章节 +- **ops-tools/ai-proj-deploy.md** - "用户管理" 章节 + +**快速参考**: +```bash +# 生成密码哈希(cost 12) +cd /path/to/new-ai-proj/backend +cat > /tmp/genhash.go << 'EOF' +package main +import ("fmt"; "golang.org/x/crypto/bcrypt") +func main() { + hash, _ := bcrypt.GenerateFromPassword([]byte("密码"), 12) + fmt.Println(string(hash)) +} +EOF +go run /tmp/genhash.go +``` + +**常见错误**:使用 cost 10 生成的哈希无法登录(后端 DefaultCost=12) + +**已创建的系统用户**: + +| 用户名 | 邮箱 | user_type | role | 创建时间 | +|--------|------|-----------|------|----------| +| qiudl | qiudl@zhiyuncai.com | system | admin | - | +| jiaxiang | jiaxiang@joylodging.com | system | admin | 2026-01 | +| haiqing | haiqing@joylodging.com | system | admin | 2026-02 | + +> 注意:密码信息不在文档中记录,如需重置请参考 ops-tools 技能文档 + +--- + +## Google 日历集成配置 + +### 环境变量 + +生产环境需要配置以下环境变量(`.env` 文件): + +```bash +GOOGLE_CLIENT_ID=<你的Client ID>.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-<你的Client Secret> +GOOGLE_PROJECT_ID=<你的项目ID> +GOOGLE_REDIRECT_URL=https://ai.pipexerp.com/api/v1/auth/google/callback +GOOGLE_CALENDAR_SCOPES=https://www.googleapis.com/auth/calendar +``` + +### 获取 Google OAuth 凭据 + +#### 1. 创建 Google Cloud 项目 + +1. 访问 [Google Cloud Console](https://console.cloud.google.com/) +2. 创建新项目或选择现有项目 +3. 记录项目 ID + +#### 2. 启用 API + +1. 进入 **APIs & Services** → **Library** +2. 搜索并启用 **Google Calendar API** + +#### 3. 配置 OAuth 同意屏幕 + +1. 进入 **APIs & Services** → **OAuth consent screen** +2. 选择 **External** → 创建 +3. 填写应用名称、支持邮箱 +4. 添加 Scopes: + - `https://www.googleapis.com/auth/calendar` + - `https://www.googleapis.com/auth/calendar.events` +5. 添加测试用户(开发阶段) + +#### 4. 创建 OAuth 凭据 + +1. 进入 **APIs & Services** → **Credentials** +2. 点击 **+ CREATE CREDENTIALS** → **OAuth client ID** +3. 选择 **Web application** +4. 配置: + - **Name**: `AI-Proj Web Client` + - **Authorized JavaScript origins**: `https://ai.pipexerp.com` + - **Authorized redirect URIs**: `https://ai.pipexerp.com/api/v1/auth/google/callback` +5. 复制 Client ID 和 Client Secret + +### 部署配置 + +```bash +# 更新生产环境配置 +ssh tools_ai_proj "cd /home/ubuntu/apps/new-ai-proj && \ +sed -i 's|GOOGLE_CLIENT_ID=.*|GOOGLE_CLIENT_ID=|' .env && \ +sed -i 's|GOOGLE_CLIENT_SECRET=.*|GOOGLE_CLIENT_SECRET=|' .env && \ +sed -i 's|GOOGLE_PROJECT_ID=.*|GOOGLE_PROJECT_ID=|' .env && \ +docker restart ai_backend_prod" +``` + +### 数据库迁移 + +Google 日历集成需要以下表: + +```sql +-- OAuth 状态表(CSRF 保护) +CREATE TABLE oauth_states ( + id SERIAL PRIMARY KEY, + state VARCHAR(255) NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 项目日历配置表 +-- 见 migrations/20260210_01_project_calendar.sql + +-- 日历 Webhook 表 +-- 见 migrations/20260211_01_calendar_webhooks.sql +``` + +### 当前配置(2026-02) + +| 配置项 | 值 | +|--------|-----| +| Project ID | `project-bfa64769-1595-4c41-964` | +| Client ID | `78721641513-...apps.googleusercontent.com` | +| Redirect URL | `https://ai.pipexerp.com/api/v1/auth/google/callback` | + +> **注意**: Client Secret 不在文档中记录,存储在生产环境 `.env` 文件中 + +--- + +## 十三、最佳实践与故障排查 + +### 13.1 创建需求的最佳实践 + +#### 问题:description 字段"请求数据格式错误" + +**症状**: +```json +{ + "code": "BAD_REQUEST", + "message": "请求数据格式错误" +} +``` + +**常见原因**: + +| 原因 | 解决方案 | +|------|----------| +| **description 内容过长** | API 可能有字段长度限制,逐步测试找到上限 | +| **Markdown 特殊字符** | 检查是否有未转义的特殊字符(如反引号、引号) | +| **JSON 转义问题** | 确保 JSON 字符串正确转义(尤其是换行符、引号) | +| **多层嵌套的代码块** | Markdown 代码块中的反引号可能导致解析失败 | + +**正确的调试流程**: + +```python +# ❌ 错误做法:直接简化内容 +"创建失败 → 简化 description → 成功" +问题:丢失了重要的需求细节 + +# ✅ 正确做法:找到根本原因 +步骤 1: 测试空描述 + create_requirement(description="") + → 是否成功? + +步骤 2: 测试纯文本 + create_requirement(description="简单文本") + → 是否成功? + +步骤 3: 测试简单 Markdown + create_requirement(description="# 标题\n\n内容") + → 是否成功? + +步骤 4: 逐步增加复杂度 + create_requirement(description="包含代码块的Markdown") + create_requirement(description="包含表格的Markdown") + create_requirement(description="完整的技术方案") + → 找到临界点 + +步骤 5: 根据根因选择方案 + - 如果是长度限制 → 分段或使用附件 + - 如果是特殊字符 → 转义或替换 + - 如果是 Markdown → 简化格式或转纯文本 +``` + +**推荐方案**: + +**方案 A:完整的需求 + 关联文档** +```typescript +// 1. 创建需求(简洁描述) +const req = await create_requirement({ + title: "思源笔记增加 MCP 协议层支持", + description: "核心方案:创建独立的 siyuan-mcp-bridge 服务...", + projectId: 162 +}); + +// 2. 创建详细的技术方案文档 +const doc = await create_requirement_document({ + requirementId: req.id, + title: "技术方案详细设计", + content: ` + # 需求背景 + ...(完整的技术方案,3000+ 字) + + # 架构设计 + ... + + # 实施路线图 + ... + ` +}); +``` + +**方案 B:分段创建需求描述** +```typescript +// 1. 创建需求(基本信息) +const req = await create_requirement({ + title: "思源笔记增加 MCP 协议层支持", + description: "简短描述", + projectId: 162 +}); + +// 2. 更新需求(逐步添加内容) +await update_requirement({ + id: req.id, + updates: { + description: description_part1 // 添加背景 + } +}); + +await update_requirement({ + id: req.id, + updates: { + description: description_part1 + description_part2 // 添加架构 + } +}); +``` + +#### 经验总结 + +**原则 1:业务优先于技术** +- ❌ 为了创建成功而牺牲需求完整性 +- ✅ 找到技术方案来满足业务需求 + +**原则 2:追根溯源** +- ❌ 基于假设快速妥协("可能是太长了") +- ✅ 通过调试找到真正的错误原因 + +**原则 3:给用户选择权** +- ❌ 自作主张简化内容 +- ✅ 询问用户的偏好和优先级 + +**原则 4:保留完整信息** +- ❌ 简化后的需求缺少关键信息 +- ✅ 通过关联文档保留详细内容 + +### 13.2 需求描述的格式建议 + +**推荐的需求描述结构**(简洁版): + +```markdown +## 核心方案 +1. 关键点 1(一句话) +2. 关键点 2(一句话) +3. 关键点 3(一句话) + +## 技术架构 +- 第1层:组件名(职责) +- 第2层:组件名(职责) +- 第3层:组件名(职责) + +## 实施周期 +X周(阶段1 + 阶段2 + 阶段3) +``` + +**详细内容放在关联文档**: +- 需求背景与业务价值 +- 详细的技术方案 +- 实施路线图与时间规划 +- 风险评估与应对措施 +- 验收标准 + +**Markdown 格式注意事项**: + +```markdown +✅ 安全的格式: +- 简单的标题(# ## ###) +- 无序列表(-) +- 有序列表(1. 2. 3.) +- 简单的表格 +- 加粗(**text**) + +⚠️ 可能有问题的格式: +- 三重反引号代码块(```) +- HTML 标签 +- 复杂的嵌套列表 +- 大量的特殊字符 +- 超长的单行文本 +``` + +### 13.3 更新需求的最佳实践 + +**使用 update_requirement 补充详细内容**: + +```typescript +// 创建需求后,逐步更新描述 +await update_requirement({ + id: 857, // REQ-20260217-0001 + updates: { + description: ` + ## 需求背景 + + ### 当前问题 + 思源笔记仅提供 REST API 接口,AI 操作复杂... + + ### 业务价值 + 为思源笔记添加 MCP 协议层支持,使其能够... + + ## 技术方案 + + ### 架构设计 + ...(详细内容) + ` + } +}); +``` + +**测试长度限制**: + +```bash +# 逐步增加内容,找到上限 +第1次: 200字 → 成功 +第2次: 500字 → 成功 +第3次: 1000字 → 成功 +第4次: 2000字 → 成功 +第5次: 5000字 → 失败 ← 找到上限 + +结论: description 字段最大约 3000-4000 字 +``` + +### 13.4 故障排查清单 + +遇到 API 错误时,按以下步骤排查: + +**步骤 1:分析错误信息** +```json +{ + "code": "BAD_REQUEST", + "message": "请求数据格式错误" +} +``` +- [ ] 错误码是什么?(BAD_REQUEST / VALIDATION_ERROR / AUTH_FAILED) +- [ ] 错误信息是否有具体提示? +- [ ] 是哪个字段导致的错误? + +**步骤 2:检查参数格式** +- [ ] 必填参数是否都提供了? +- [ ] 参数类型是否正确?(number vs string) +- [ ] 枚举值是否在允许范围内? +- [ ] JSON 格式是否正确? + +**步骤 3:简化测试** +- [ ] 尝试最小化的参数集 +- [ ] 逐个添加可选参数 +- [ ] 找到导致错误的具体参数 + +**步骤 4:查看后端日志**(如有权限) +```bash +# 生产环境日志 +ssh tools_ai_proj "docker logs ai_backend_prod --tail 100" + +# 开发环境日志 +cd ~/coding/new-ai-proj/backend +tail -f logs/app.log +``` + +**步骤 5:参考类似的成功案例** +- [ ] 查看 SKILL.md 中的示例 +- [ ] 查看之前成功的调用记录 +- [ ] 对比参数差异 + +### 13.5 常见错误与解决方案 + +| 错误 | 可能原因 | 解决方案 | +|------|----------|----------| +| `BAD_REQUEST` | 参数格式错误 | 检查参数类型和 JSON 格式 | +| `VALIDATION_ERROR` | 参数验证失败 | 检查必填字段和枚举值 | +| `AUTH_FAILED` | 认证失败 | 检查 Token 是否过期 | +| `PERMISSION_DENIED` | 权限不足 | 检查用户角色和权限 | +| `NOT_FOUND` | 资源不存在 | 检查 ID 是否正确 | +| `INTERNAL_ERROR` | 服务器错误 | 查看后端日志排查 | + +### 13.6 需求 description 长度测试记录 + +**测试日期**: 2026-02-17 + +| 长度 | 格式 | 结果 | 备注 | +|------|------|------|------| +| ~200字 | 简单文本 | ✅ 成功 | 核心方案3点 | +| ~3000字 | 完整 Markdown | ❌ 失败 | "请求数据格式错误" | +| 待测试 | 1000字 Markdown | ? | 需要进一步测试 | +| 待测试 | 2000字 Markdown | ? | 需要进一步测试 | + +**建议**: +- 需求描述控制在 1000 字以内 +- 详细内容使用关联文档 +- 复杂格式改为纯文本 diff --git a/plugins/biz-contract-plugin/.claude-plugin/plugin.json b/plugins/biz-contract-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..a732260 --- /dev/null +++ b/plugins/biz-contract-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "biz-contract-plugin", + "description": "商务合同撰写。支持多种合同类型:软件订阅合同、软件定制开发合同、物流合同、销售服务合同、贸易合同等。当用户提到合同、协议、签约、合作协议相关任务时自动激活。", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/biz-contract-plugin/skills/SKILL.md b/plugins/biz-contract-plugin/skills/SKILL.md new file mode 100644 index 0000000..08f197f --- /dev/null +++ b/plugins/biz-contract-plugin/skills/SKILL.md @@ -0,0 +1,742 @@ +--- +name: biz-contract +description: 商务合同撰写。支持多种合同类型:软件订阅合同、软件定制开发合同、物流合同、销售服务合同、贸易合同等。当用户提到合同、协议、签约、合作协议相关任务时自动激活。 +--- + +# 商务合同撰写技能 (biz-contract) + +## 概述 + +本技能用于辅助商务合同的撰写与管理,支持多家公司主体、多种合同类型。 + +--- + +## 用户公司信息 + +### 公司主体列表 + +| 公司简称 | 公司全称 | 纳税人识别号 | 地址 | 联系电话 | 法定代表人 | +|----------|----------|--------------|------|----------|------------| +| 智慧云彩 | 北京智慧云彩电子商务科技有限公司 | 91110114MA004M80XX | 北京市海淀区丰智东路13号院1号楼1层101 | - | - | +| 欢乐宿 | 北京欢乐宿供应链科技有限公司 | 91110113MACWJKYU8P | 北京市顺义区军营南街10号院3幢3层327室 | - | - | +| 对丝 | 北京对丝信息技术有限公司 | 91110113MAE4XXHR5E | 北京市顺义区军营南街10号院3幢3层3216室 | - | - | +| 妗晨 | 重庆妗晨工贸有限公司 | 91500104MA7EJTPA6D | 重庆市大渡口区跳磴镇海康路106号1-1 | 15213397998 | - | +| 名风 | 北京名风新能源科技有限公司 | 91110106092440790K | 北京市密云区西田各庄镇雁密路99号601室-3509(集群注册) | - | 魏小健 | +| 鸿侨 | 沈阳鸿侨物流有限公司 | 91210113MADBXPU93P | 辽宁省沈阳市沈北新区蒲河路49-2号(1-29-2) | 15567339320 | 禚子乔 | + +### 开户信息 + +| 公司简称 | 开户银行 | 银行账号 | +|----------|----------|----------| +| 智慧云彩 | 招商银行股份有限公司北京立水桥支行 | 110922001210512 | +| 欢乐宿 | 中国工商银行北京南十里支行 | 0200206709200036024 | +| 对丝 | 招商银行股份有限公司北京顺义支行 | 110961225610001 | +| 妗晨 | 中国农业银行股份有限公司重庆大渡口天安支行 | 31230401040004386 | +| 名风 | 中国建设银行北京黄亦路支行 | 11001181700052501668 | +| 鸿侨 | 中国建设银行股份有限公司沈阳辉山支行 | 21050149004200001104 | + +--- + +## 支持的合同类型 + +| 类型 | 说明 | 典型场景 | +|------|------|----------| +| 软件订阅合同 | SaaS 软件服务订阅 | 云服务、在线系统 | +| 软件定制开发合同 | 软件外包开发 | 项目制开发 | +| 物流合同 | 物流运输服务 | 货运、仓储 | +| 销售服务合同 | 产品销售与服务 | 商品买卖 | +| 贸易合同 | 商品贸易 | 进出口、批发 | +| 技术服务合同 | 技术咨询、支持 | IT 服务 | +| 保密协议 (NDA) | 信息保密 | 合作前签署 | + +--- + +## 合同撰写流程 + +``` +1. 确定合同类型 + ├── 识别业务场景 + ├── 选择合同模板 + └── 确定双方主体 + +2. 收集合同要素 + ├── 甲方/乙方信息 + ├── 标的物/服务内容 + ├── 价款与支付方式 + ├── 履行期限 + └── 特殊条款需求 + +3. 生成合同草稿 + ├── 套用模板 + ├── 填充具体内容 + └── 调整条款 + +4. 审核与完善 + ├── 法律合规检查 + ├── 风险条款审查 + └── 最终定稿 +``` + +--- + +## 合同模板 + +### 1. 软件订阅合同 (SaaS) + +```markdown +# 软件订阅服务合同 + +**合同编号**: [自动生成或手填] + +**甲方(服务提供方)**: [公司全称] +统一社会信用代码: [代码] +地址: [地址] +法定代表人: [姓名] + +**乙方(订阅方)**: [公司全称] +统一社会信用代码: [代码] +地址: [地址] +法定代表人: [姓名] + +--- + +鉴于甲方拥有 [软件名称] 的合法运营权,乙方有意订阅使用该软件服务,双方本着平等互利的原则,经友好协商,达成如下协议: + +## 第一条 软件服务内容 + +1.1 软件名称:[软件名称] +1.2 服务内容:[功能模块描述,或"详见系统操作手册"] +1.3 服务方式:SaaS 云服务模式,乙方通过互联网访问使用 +1.4 账号数量:[数量] 个用户账号 + +## 第二条 服务期限 + +2.1 服务期限:自 [起始日期] 至 [结束日期] +2.2 续约条款:合同期满前 [30] 日,双方均未提出书面异议的,本合同自动续期 [1] 年,届时服务费按届时标准执行 + +## 第三条 服务费用与支付 + +3.1 服务费用: + - 月付标准:人民币 [金额] 元/月 + - 年付标准:人民币 [金额] 元/年 + +3.2 支付方式:[年付/月付/季付] +3.3 付款时间:乙方应于本合同签订后 [N] 个工作日内,在收到甲方开具的发票后,一次性支付服务费用 +3.4 付款账户: + 开户名称:[甲方公司全称] + 开户银行:[银行名称] + 银行账号:[账号] + +## 第四条 甲方权利与义务 + +4.1 甲方应保证软件服务的稳定运行,年可用性不低于 99.5% +4.2 甲方应提供必要的技术支持和培训服务 +4.3 甲方应对乙方的业务数据严格保密 +4.4 甲方有权在乙方未按时付款时暂停服务 + +## 第五条 乙方权利与义务 + +5.1 乙方有权在服务期内正常使用软件服务 +5.2 乙方应按约定及时支付服务费用 +5.3 乙方不得将账号转让、出租给第三方 +5.4 乙方应妥善保管账号密码,因保管不善造成的损失由乙方承担 + +## 第六条 数据安全与保密 + +6.1 甲方应采取合理的技术措施保护乙方数据安全 +6.2 未经对方书面同意,任何一方不得向第三方披露本合同内容及对方商业秘密 +6.3 保密义务在合同终止后 [2] 年内继续有效 + +## 第七条 违约责任 + +7.1 任何一方违反本合同约定,应向守约方支付合同总金额 [10]% 的违约金 +7.2 因甲方原因导致服务中断超过 [24] 小时的,甲方应按日退还相应服务费 + +## 第八条 合同变更与解除 + +8.1 经双方协商一致,可以变更或解除本合同 +8.2 乙方提前解约的,已付费用不予退还 +8.3 甲方提前解约的,应退还乙方剩余期限的服务费用 + +## 第九条 争议解决 + +9.1 本合同的签订、履行、解释均适用中华人民共和国法律 +9.2 双方因履行本合同发生争议,应友好协商解决;协商不成的,任何一方均可向甲方所在地人民法院提起诉讼 + +## 第十条 其他条款 + +10.1 本合同一式两份,双方各执一份,具有同等法律效力 +10.2 本合同自双方签字盖章之日起生效 +10.3 本合同未尽事宜,由双方另行协商签订补充协议 + +--- + +**甲方(盖章)**: **乙方(盖章)**: + +法定代表人/授权代表: 法定代表人/授权代表: + +日期: 日期: +``` + +--- + +### 2. 软件定制开发合同 + +```markdown +# 软件定制开发合同 + +**合同编号**: [编号] + +**甲方(委托方)**: [公司全称] +**乙方(开发方)**: [公司全称] + +--- + +## 第一条 项目内容 + +1.1 项目名称:[项目名称] +1.2 开发内容:[详细描述或附件《需求规格说明书》] +1.3 技术要求:[技术栈、性能指标等] +1.4 交付物: + - 软件源代码 + - 部署文档 + - 操作手册 + - [其他] + +## 第二条 开发周期 + +2.1 总工期:[N] 个工作日 +2.2 里程碑: + | 阶段 | 内容 | 交付时间 | + |------|------|----------| + | 需求确认 | 需求规格说明书 | [日期] | + | 设计阶段 | 设计文档 | [日期] | + | 开发阶段 | 功能开发完成 | [日期] | + | 测试阶段 | 测试报告 | [日期] | + | 验收交付 | 全部交付物 | [日期] | + +## 第三条 合同金额与支付 + +3.1 合同总金额:人民币 [金额] 元(大写:[大写金额]) +3.2 支付方式: + - 合同签订后 [N] 日内,支付 [30]%,即 [金额] 元 + - 需求确认后 [N] 日内,支付 [30]%,即 [金额] 元 + - 验收通过后 [N] 日内,支付 [40]%,即 [金额] 元 + +## 第四条 验收标准 + +4.1 乙方完成开发后,应书面通知甲方验收 +4.2 甲方应在收到通知后 [10] 个工作日内完成验收 +4.3 验收标准以《需求规格说明书》为准 +4.4 甲方逾期未验收且未提出书面异议的,视为验收通过 + +## 第五条 知识产权 + +5.1 定制开发的软件著作权归 [甲方/乙方/共有] +5.2 乙方保证交付的软件不侵犯任何第三方知识产权 + +## 第六条 质保与维护 + +6.1 免费质保期:验收通过后 [12] 个月 +6.2 质保期内,乙方应免费修复软件缺陷 +6.3 质保期后,双方可另行签订维护协议 + +## 第七条 违约责任 + +7.1 乙方逾期交付,每逾期一日,应向甲方支付合同总金额 [0.5]‰ 的违约金 +7.2 甲方逾期付款,每逾期一日,应向乙方支付应付金额 [0.5]‰ 的违约金 + +## 第八条 其他条款 + +[参照标准条款] + +--- + +**甲方(盖章)**: **乙方(盖章)**: + +日期: 日期: +``` + +--- + +### 3. 物流服务合同 + +**物流服务增值税税率:9%** + +```markdown +# 物流服务合同 + +**合同编号**: [编号] + +**甲方(托运方)**: [公司全称] +**统一社会信用代码**: [代码] +**地址**: [地址] +**联系电话**: [电话] + +**乙方(承运方)**: [公司全称] +**统一社会信用代码**: [代码] +**地址**: [地址] +**法定代表人**: [姓名] + +--- + +## 第一条 运输服务内容 + +1.1 服务类型:[整车运输/零担运输/仓储配送] +1.2 运输路线:[起点] 至 [终点] +1.3 货物类型:[货物描述] + +## 第二条 服务期限 + +2.1 合同有效期:自 [起始日期] 至 [结束日期] + +## 第三条 运费与结算 + +3.1 计费方式:[按重量/体积/件数/趟次] +3.2 运费标准:[具体标准] +3.3 结算周期:[月结/单结] +3.4 付款期限:甲方在收到乙方对账单并确认无误后 [N] 个工作日内支付运费 + +## 第四条 发票与税务 + +4.1 发票类型:增值税专用发票 +4.2 税率:9%(交通运输服务) +4.3 开票时间:乙方应在甲方付款前开具发票 +4.4 发票内容:运输服务费 + +### 甲方开票信息(付款方) +- 名称:[甲方公司全称] +- 纳税人识别号:[税号] +- 地址、电话:[地址] [电话] +- 开户行及账号:[开户银行] [账号] + +### 乙方开票信息(收款方) +- 名称:[乙方公司全称] +- 纳税人识别号:[税号] +- 地址、电话:[地址] [电话] +- 开户行及账号:[开户银行] [账号] + +## 第五条 甲方权利与义务 + +5.1 如实申报货物名称、数量、重量 +5.2 按约定及时支付运费 +5.3 提供必要的装卸条件 + +## 第六条 乙方权利与义务 + +6.1 按时提货、送货 +6.2 保证货物运输安全 +6.3 提供运输单据和签收证明 +6.4 按约定开具增值税专用发票 + +## 第七条 货损赔偿 + +7.1 因乙方原因造成货物损失,按货物实际价值赔偿,最高不超过运费的 [N] 倍 +7.2 甲方应在签收时验货,当场提出异议 + +## 第八条 其他条款 + +[参照标准条款] + +--- + +### 签章 + +| | 甲方 | 乙方 | +|---|---|---| +| 单位名称 | | | +| 法定代表人/授权代表 |(签字)|(签字)| +| 单位盖章 |(盖章处)|(盖章处)| +| 签订日期 | 年 月 日 | 年 月 日 | +``` + +#### 物流合同开票信息速查 + +| 公司简称 | 公司全称 | 纳税人识别号 | 开票地址电话 | 开户行及账号 | +|----------|----------|--------------|--------------|--------------| +| 妗晨 | 重庆妗晨工贸有限公司 | 91500104MA7EJTPA6D | 重庆市大渡口区跳磴镇海康路106号1-1 / 15213397998 | 中国农业银行重庆大渡口天安支行 31230401040004386 | +| 名风 | 北京名风新能源科技有限公司 | 91110106092440790K | 北京市密云区西田各庄镇雁密路99号601室-3509 / - | 中国建设银行北京黄亦路支行 11001181700052501668 | +| 鸿侨 | 沈阳鸿侨物流有限公司 | 91210113MADBXPU93P | 辽宁省沈阳市沈北新区蒲河路49-2号(1-29-2) / 15567339320 | 中国建设银行沈阳辉山支行 21050149004200001104 | + +--- + +### 4. 销售服务合同 + +```markdown +# 销售服务合同 + +**合同编号**: [编号] + +**甲方(销售方)**: [公司全称] +**乙方(购买方)**: [公司全称] + +--- + +## 第一条 产品/服务内容 + +| 序号 | 名称 | 规格型号 | 单位 | 数量 | 单价(元) | 金额(元) | +|------|------|----------|------|------|------------|------------| +| 1 | | | | | | | +| 2 | | | | | | | +| **合计** | | | | | | **[总金额]** | + +## 第二条 合同金额 + +2.1 合同总金额:人民币 [金额] 元(大写:[大写金额]) +2.2 以上价格 [含/不含] 增值税 + +## 第三条 交付方式 + +3.1 交付时间:[日期] +3.2 交付地点:[地址] +3.3 交付方式:[送货上门/自提/物流] + +## 第四条 付款方式 + +4.1 [全款预付/货到付款/分期付款] +4.2 付款账户:[账户信息] + +## 第五条 验收标准 + +5.1 乙方应在收货后 [N] 日内完成验收 +5.2 验收标准:[标准描述] + +## 第六条 售后服务 + +6.1 质保期:[N] 个月 +6.2 质保内容:[描述] + +## 第七条 违约责任 + +[标准条款] + +--- + +**甲方(盖章)**: **乙方(盖章)**: + +日期: 日期: +``` + +--- + +### 5. 贸易合同 + +```markdown +# 贸易合同 + +**合同编号**: [编号] + +**甲方(供货方)**: [公司全称] +**乙方(采购方)**: [公司全称] + +--- + +## 第一条 货物明细 + +| 品名 | 规格 | 产地 | 单位 | 数量 | 单价 | 金额 | +|------|------|------|------|------|------|------| +| | | | | | | | +| **合计** | | | | | | | + +## 第二条 质量标准 + +2.1 质量标准:[国标/行标/企标/样品] +2.2 检验方式:[抽检/全检] + +## 第三条 包装要求 + +3.1 包装方式:[描述] +3.2 包装费用:[含在货款中/另计] + +## 第四条 交货 + +4.1 交货时间:[日期] +4.2 交货地点:[地址] +4.3 运输方式:[描述] +4.4 运费承担:[甲方/乙方] + +## 第五条 价款与支付 + +5.1 合同总价:人民币 [金额] 元 +5.2 付款方式:[预付款比例、货到付款等] +5.3 发票类型:[增值税专用发票/普通发票] + +## 第六条 验收与异议 + +6.1 验收期限:收货后 [N] 日内 +6.2 异议期限:发现质量问题后 [N] 日内书面提出 + +## 第七条 违约责任 + +7.1 甲方逾期交货,每日按合同金额 [0.5]‰ 支付违约金 +7.2 乙方逾期付款,每日按应付金额 [0.5]‰ 支付违约金 + +--- + +**甲方(盖章)**: **乙方(盖章)**: + +日期: 日期: +``` + +--- + +## 合同要素检查清单 + +撰写合同时,确保包含以下要素: + +### 基本要素 +- [ ] 合同编号 +- [ ] 合同标题 +- [ ] 签约双方信息(全称、代码、地址、法人) +- [ ] 签订日期 + +### 核心条款 +- [ ] 标的物/服务内容(明确、具体) +- [ ] 数量/规格 +- [ ] 价款/费用 +- [ ] 支付方式与时间 +- [ ] 履行期限/服务期限 +- [ ] 交付方式/地点 + +### 权责条款 +- [ ] 甲方权利与义务 +- [ ] 乙方权利与义务 +- [ ] 验收标准 +- [ ] 质保条款 + +### 风险条款 +- [ ] 违约责任 +- [ ] 争议解决方式 +- [ ] 不可抗力 +- [ ] 保密条款 + +### 其他 +- [ ] 合同变更与解除 +- [ ] 通知送达方式 +- [ ] 合同份数 +- [ ] 生效条件 +- [ ] 签字盖章位置 + +--- + +## 使用说明 + +### 快速生成合同 + +向我描述合同需求,我会帮您生成合同草稿: + +**示例输入**: +> 拟定一份软件订阅合同,北京智慧云彩为服务方,重庆妗晨为订阅方,软件是智云物流管理系统,年费2.4万,合同期1年,到期自动续约 + +**我会输出**: +- 完整的合同文本 +- 需要补充的信息提示 +- 风险提示(如有) + +### 合同审核 + +您也可以提供合同文本让我审核,我会检查: +- 条款完整性 +- 权责平衡性 +- 法律风险点 +- 用语规范性 + +--- + +## 注意事项 + +1. **本技能生成的合同仅供参考**,重要合同建议经法务/律师审核 +2. 涉及大额交易、复杂条款的合同,建议专业法律意见 +3. 请确保公司信息准确无误后再签署 +4. 合同金额大写与小写应一致 +5. 注意保留合同签署过程的证据 + +--- + +## 思源笔记集成(推荐) + +合同文档优先保存到思源笔记,校对确认后再发布到云文档。 + +### 工作流程 + +``` +合同技能生成内容 → 思源笔记(预览/校对) → 飞书云文档(正式发布) +``` + +### 笔记本配置 + +| 配置项 | 值 | +|--------|-----| +| 笔记本名称 | 商务合同 | +| 笔记本ID | `20260202080313-kjtgg1j` | + +### 路径规范 + +``` +/商务合同/ +├── 物流合同/ +│ ├── 妗晨-名风-2026 +│ └── 妗晨-鸿侨-2026 +├── 软件订阅合同/ +│ └── 智慧云彩-xxx-2026 +└── 贸易合同/ + └── ... +``` + +路径格式:`/{合同类型}/{甲方简称}-{乙方简称}-{年份}` + +### 使用示例 + +```python +from siyuan_api import SiYuanAPI + +api = SiYuanAPI() +NOTEBOOK_ID = "20260202080313-kjtgg1j" + +# 1. 创建合同到思源笔记 +contract_md = generate_contract_markdown(...) +doc_id = api.upsert_doc( + NOTEBOOK_ID, + "/物流合同/妗晨-名风-2026", + contract_md +) +print(f"请在思源笔记中校对: {doc_id}") + +# 2. 校对完成后,发布到飞书 +api.publish_to_feishu(doc_id, "物流服务合同-妗晨与名风") +``` + +### 文档状态标记 + +| 状态 | 含义 | +|------|------| +| 📝 草稿 | 初始生成,待校对 | +| 🔍 校对中 | 正在审核内容 | +| ✅ 已确认 | 校对完成,可发布 | +| 🚀 已发布 | 已发布到云文档 | + +--- + +## 与 ai-proj 集成 + +合同相关需求可创建到 ai-proj 系统: + +``` +类型: biz (业务类型) +分类: other +示例标题: 拟定 XXX 与 YYY 的软件订阅合同 +``` + +合同完成后,可作为文档附件关联到对应需求。 + +--- + +## 飞书文档集成 + +本技能已集成飞书文档,可将合同直接创建到飞书云文档。 + +### 权限说明 + +**重要**:飞书文档默认对组织内权限是开放的,允许编辑。创建的合同文档在组织内成员均可查看和编辑。如需限制访问权限,请在飞书中手动调整文档权限设置。 + +### 可用工具 + +| 工具 | 功能 | +|------|------| +| `create_document` | 创建空白飞书文档 | +| `write_document` | 向已有文档写入内容 | +| `create_and_write_document` | 创建文档并写入内容(推荐) | +| `get_document_info` | 获取文档信息 | + +### 使用示例 + +``` +# 创建合同文档 +使用 feishu-doc MCP 的 create_and_write_document 工具: +- title: "软件订阅服务合同-智慧云彩与妗晨" +- content: [Markdown 格式的合同内容] +- folder_token: [可选,指定文件夹] +``` + +### 获取文件夹 Token + +1. 在飞书中打开目标文件夹 +2. 复制 URL 中的 folder token(格式如:`fldcnXXXXXX`) +3. 调用工具时传入 folder_token 参数 + +### 飞书输出格式规范 + +#### 开票信息表格 + +物流合同中的开票信息应使用**表格**格式展示,列宽配置: + +| 配置项 | 值 | 说明 | +|--------|-----|------| +| 项目列宽 | 120px | 显示"名称"、"纳税人识别号"等标签 | +| 内容列宽 | 480px | 显示具体信息 | + +```python +# 创建开票信息表格示例 +from feishu_docx import FeishuDocx + +docx = FeishuDocx() +doc = docx.create_document("物流服务合同") + +# 创建表格(4行2列,指定列宽) +table = docx.create_table(doc["document_id"], rows=4, cols=2, col_widths=[120, 480]) + +# 填充内容 +docx.fill_table(doc["document_id"], table, [ + ["名称", "重庆妗晨工贸有限公司"], + ["纳税人识别号", "91500104MA7EJTPA6D"], + ["地址、电话", "重庆市大渡口区跳磴镇海康路106号1-1 / 15213397998"], + ["开户行及账号", "中国农业银行重庆大渡口天安支行 31230401040004386"], +]) +``` + +#### 签章格式 + +推荐使用**独立区块式**签章格式: + +``` +【甲 方】 + +单位名称:重庆妗晨工贸有限公司 + +法定代表人(或授权代表): (签字/盖章) + +单位盖章: + + + +签订日期: 年 月 日 + +───────────────────────────────────────────────────── + +【乙 方】 + +单位名称:北京名风新能源科技有限公司 + +法定代表人(或授权代表): (签字/盖章) + +单位盖章: + + + +签订日期: 年 月 日 +``` + +**特点**:甲乙双方独立区块,留白充足,便于盖章签字。 + +#### 物流合同税率 + +| 服务类型 | 税率 | 发票类型 | +|----------|------|----------| +| 交通运输服务 | 9% | 增值税专用发票 | + +#### 默认存储路径 + +| 平台 | 路径 | +|------|------| +| 飞书云文档 | ai-proj/01运营 (folder_token: `C80gfkRnzlonQ5d4AhOcOACDnNg`) | +| 思源笔记 | 商务合同笔记本 /物流合同/{甲方简称}-{乙方简称}-{年份} | diff --git a/plugins/biz-ops-plugin/.claude-plugin/plugin.json b/plugins/biz-ops-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..bfcd3a6 --- /dev/null +++ b/plugins/biz-ops-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "biz-ops-plugin", + "description": "商务运营技能。支持商业计划书(BP)撰写和商务合同起草。当用户提到商业计划书、BP、融资计划、商业模式、合同、协议、签约等相关任务时自动激活。", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/biz-ops-plugin/skills/SKILL.md b/plugins/biz-ops-plugin/skills/SKILL.md new file mode 100644 index 0000000..b605d84 --- /dev/null +++ b/plugins/biz-ops-plugin/skills/SKILL.md @@ -0,0 +1,178 @@ +--- +name: biz-ops +description: 商务运营技能。支持商业计划书(BP)撰写和商务合同起草。当用户提到商业计划书、BP、融资计划、商业模式、合同、协议、签约等相关任务时自动激活。 +--- + +# 商务运营 (biz-ops) + +## 一、商业计划书 (BP) + +### 公司背景 + +| 项目 | 描述 | +|------|------| +| 公司定位 | 酒店供应链系统研发公司 | +| 核心能力 | 软件系统研发 + 供应链运营经验 | +| 商业模式 | 换房额度 → 供应链平台采购 → 交易佣金 | + +**换房模式**:酒店提供换房额度 → 转化为平台采购额度 → 酒店消费 → 平台抽佣(X%) + +### 标准 BP 结构(10-15页) + +| 章节 | 页数 | 核心内容 | +|------|------|----------| +| 1. 封面 | 1 | 公司名称、Slogan、联系方式 | +| 2. 公司简介 | 1 | 一句话定位、发展历程、里程碑 | +| 3. 痛点与机会 | 1-2 | 行业痛点、市场机会、为什么是现在 | +| 4. 解决方案 | 2 | 产品/服务介绍、核心功能、价值主张 | +| 5. 商业模式 | 1-2 | 如何赚钱、收入来源、定价策略 | +| 6. 市场规模 | 1 | TAM/SAM/SOM(酒店采购:TAM 2000亿,SOM 100亿) | +| 7. 竞争分析 | 1 | 竞争格局、差异化:软件+供应链双轮 | +| 8. 运营数据 | 1-2 | GMV、客户数、复购率、增长曲线 | +| 9. 团队介绍 | 1 | 核心成员、背景优势 | +| 10. 融资计划 | 1 | 金额、估值、用途(研发40%/市场30%/运营20%/储备10%) | + +### 商业模式收入模型 + +| 收入来源 | 说明 | +|----------|------| +| 交易佣金 | 供应链交易抽佣 (3-8%) | +| SaaS 订阅费 | 系统使用年费 | +| 金融服务 | 供应链金融分润 | +| 增值服务 | 数据服务、营销服务 | + +**Unit Economics**:CAC / LTV(健康值 LTV/CAC >3)/ 毛利率 / 回本周期 + +### PPT 输出 + +生成路演 PPT 时,输出为单个 HTML 文件(全屏演示): +- 支持键盘翻页(← →)、F 全屏、触摸滑动 +- 配色:深色科技风 `#1a1a2e` 背景 + `#667eea` 主色 +- 数据卡片 + 两栏布局 + 流程图组件 +- 保存到桌面后 `open ~/Desktop/bp-presentation.html` + +**PPT 设计原则**:一页一重点 / 大字(标题48px+,正文24px+)/ 数据可视化 / 留白充足 + +--- + +## 二、商务合同 + +### 用户公司信息 + +| 公司简称 | 公司全称 | 纳税人识别号 | 地址 | 联系电话 | 法定代表人 | +|----------|----------|--------------|------|----------|------------| +| 智慧云彩 | 北京智慧云彩电子商务科技有限公司 | 91110114MA004M80XX | 北京市海淀区丰智东路13号院1号楼1层101 | - | - | +| 欢乐宿 | 北京欢乐宿供应链科技有限公司 | 91110113MACWJKYU8P | 北京市顺义区军营南街10号院3幢3层327室 | - | - | +| 对丝 | 北京对丝信息技术有限公司 | 91110113MAE4XXHR5E | 北京市顺义区军营南街10号院3幢3层3216室 | - | - | +| 妗晨 | 重庆妗晨工贸有限公司 | 91500104MA7EJTPA6D | 重庆市大渡口区跳磴镇海康路106号1-1 | 15213397998 | - | +| 名风 | 北京名风新能源科技有限公司 | 91110106092440790K | 北京市密云区西田各庄镇雁密路99号601室-3509(集群注册) | - | 魏小健 | +| 鸿侨 | 沈阳鸿侨物流有限公司 | 91210113MADBXPU93P | 辽宁省沈阳市沈北新区蒲河路49-2号(1-29-2) | 15567339320 | 禚子乔 | + +### 开户信息 + +| 公司简称 | 开户银行 | 银行账号 | +|----------|----------|----------| +| 智慧云彩 | 招商银行股份有限公司北京立水桥支行 | 110922001210512 | +| 欢乐宿 | 中国工商银行北京南十里支行 | 0200206709200036024 | +| 对丝 | 招商银行股份有限公司北京顺义支行 | 110961225610001 | +| 妗晨 | 中国农业银行股份有限公司重庆大渡口天安支行 | 31230401040004386 | +| 名风 | 中国建设银行北京黄亦路支行 | 11001181700052501668 | +| 鸿侨 | 中国建设银行股份有限公司沈阳辉山支行 | 21050149004200001104 | + +### 支持的合同类型 + +| 类型 | 典型场景 | 税率 | +|------|----------|------| +| 软件订阅合同 (SaaS) | 云服务、在线系统 | 6% | +| 软件定制开发合同 | 项目制开发 | 6% | +| 物流服务合同 | 货运、仓储 | **9%**(交通运输服务) | +| 销售服务合同 | 商品买卖 | 13% | +| 贸易合同 | 批发、进出口 | 13% | +| 技术服务合同 | IT 咨询 | 6% | +| 保密协议 (NDA) | 合作前签署 | - | + +### 合同模板速查 + +#### SaaS 软件订阅合同核心条款 + +``` +第一条 软件服务内容(名称、模块、账号数、SaaS云服务模式) +第二条 服务期限(起止日期、到期前30日未异议自动续期) +第三条 服务费用与支付(月付/年付标准、付款时间、付款账户) +第四条 甲方义务(年可用性≥99.5%、技术支持、数据保密、逾期可暂停服务) +第五条 乙方义务(按时付款、禁止转让账号) +第六条 数据安全与保密(保密义务合同终止后2年继续有效) +第七条 违约责任(违约金=合同总额10%;服务中断>24小时按日退费) +第八条 合同变更与解除(乙方提前解约已付费不退;甲方提前解约退剩余) +第九条 争议解决(甲方所在地法院) +``` + +#### 软件定制开发合同核心条款 + +``` +第一条 项目内容(名称、开发内容/需求规格说明书、技术要求、交付物清单) +第二条 开发周期(总工期、里程碑:需求确认→设计→开发→测试→验收) +第三条 付款节点(签约30%→需求确认30%→验收通过40%) +第四条 验收标准(收通知后10个工作日内;逾期未验收视为通过) +第五条 知识产权(归甲方/乙方/共有) +第六条 质保与维护(免费质保期12个月) +第七条 违约责任(逾期交付:合同额0.5‰/日;逾期付款:应付额0.5‰/日) +``` + +#### 物流服务合同核心条款 + +``` +税率:9%(交通运输服务增值税专用发票) +第一条 运输服务内容(整车/零担/仓储配送、路线、货物类型) +第二条 服务期限 +第三条 运费与结算(计费方式、月结/单结、付款期限) +第四条 发票与税务(9%增值税专用发票;开票时间:付款前) +第五条/六条 甲乙方权利义务 +第七条 货损赔偿(实际价值赔偿,最高不超过运费N倍;当场提出异议) +``` + +**签章格式**(推荐独立区块式): + +``` +【甲 方】 +单位名称: +法定代表人(或授权代表): (签字/盖章) +单位盖章: +签订日期: 年 月 日 +───────────────────────────────────────────── +【乙 方】 +... +``` + +### 合同要素检查清单 + +- [ ] 合同编号、标题、签订日期 +- [ ] 双方全称、统一社会信用代码、地址、法人 +- [ ] 标的物/服务内容(明确具体) +- [ ] 价款、支付方式与时间 +- [ ] 履行期限/交付方式 +- [ ] 验收标准、质保条款 +- [ ] 违约责任(违约金比例) +- [ ] 争议解决方式 +- [ ] 不可抗力、保密条款 +- [ ] 合同份数、生效条件 + +### 文档存储 + +| 平台 | 路径/Token | +|------|------------| +| 飞书云文档 | ai-proj/01运营 (`C80gfkRnzlonQ5d4AhOcOACDnNg`) | +| 思源笔记 | 商务合同笔记本 ID: `20260202080313-kjtgg1j` | + +思源路径规范:`/{合同类型}/{甲方简称}-{乙方简称}-{年份}` + +工作流:合同技能生成内容 → 思源笔记(校对)→ 飞书云文档(正式发布) + +--- + +## 注意事项 + +1. BP 数据必须真实可查证,敏感数据可用 [X] 占位 +2. 合同金额大写与小写必须一致 +3. **本技能生成的合同仅供参考**,重要合同建议经法务/律师审核 +4. 公司信息确认无误后再签署 diff --git a/plugins/biz-plan-plugin/.claude-plugin/plugin.json b/plugins/biz-plan-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..0b4ec6b --- /dev/null +++ b/plugins/biz-plan-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "biz-plan-plugin", + "description": "Plugin for biz-plan", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/biz-plan-plugin/skills/SKILL.md b/plugins/biz-plan-plugin/skills/SKILL.md new file mode 100644 index 0000000..4fa8b05 --- /dev/null +++ b/plugins/biz-plan-plugin/skills/SKILL.md @@ -0,0 +1,760 @@ +--- +name: biz-plan +description: 商业计划书撰写。支持融资BP、战略规划、商业模式设计等。当用户提到商业计划书、BP、融资计划、商业模式、战略规划相关任务时自动激活。 +--- + +# 商业计划书撰写技能 (biz-plan) + +## 概述 + +本技能用于辅助商业计划书的撰写与优化,特别适用于 B2B SaaS、供应链平台、产业互联网等领域。 + +--- + +## 公司背景(可定制) + +### 核心信息 + +| 项目 | 描述 | +|------|------| +| 公司定位 | 酒店供应链系统研发公司 | +| 发展阶段 | 多轮融资,成熟期 | +| 核心能力 | 软件系统研发 + 供应链运营经验 | +| 资源优势 | 头部酒店集团资源 | + +### 商业模式 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 平台商业模式 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ 换房额度 ┌──────────────┐ │ +│ │ 酒店端 │ ──────────────────→ │ 供应链平台 │ │ +│ │ │ ←────────────────── │ (集团总部) │ │ +│ └──────────┘ 采购消费 └──────────────┘ │ +│ │ │ │ +│ │ 房间资源 │ 商品/服务 │ +│ ↓ ↓ │ +│ ┌──────────┐ ┌──────────────┐ │ +│ │ 换房用户 │ │ 供应商 │ │ +│ └──────────┘ └──────────────┘ │ +│ │ +│ 收入来源: 供应链交易服务佣金 │ +│ 核心价值: 软件系统增强上下游粘性 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 商业计划书结构 + +### 标准融资 BP 结构(10-15页) + +| 章节 | 页数 | 核心内容 | +|------|------|----------| +| 1. 封面 | 1 | 公司名称、Slogan、联系方式 | +| 2. 公司简介 | 1 | 一句话定位、发展历程、里程碑 | +| 3. 痛点与机会 | 1-2 | 行业痛点、市场机会、为什么是现在 | +| 4. 解决方案 | 2 | 产品/服务介绍、核心功能、价值主张 | +| 5. 商业模式 | 1-2 | 如何赚钱、收入来源、定价策略 | +| 6. 市场规模 | 1 | TAM/SAM/SOM、增长趋势 | +| 7. 竞争分析 | 1 | 竞争格局、差异化优势 | +| 8. 运营数据 | 1-2 | 关键指标、增长曲线、客户案例 | +| 9. 团队介绍 | 1 | 核心成员、背景优势 | +| 10. 融资计划 | 1 | 融资金额、估值、资金用途 | + +--- + +## 各章节撰写指南 + +### 1. 封面 + +``` +[公司 LOGO] + +公司名称 +—————————————— +一句话 Slogan(不超过20字) + +联系人:XXX +电话:XXX +邮箱:XXX + +[日期] +[保密声明] +``` + +### 2. 公司简介 + +**一句话定位模板**: +> 我们是 [行业] 领域的 [产品类型],通过 [核心能力],帮助 [目标客户] 实现 [核心价值]。 + +**示例**: +> 我们是酒店供应链领域的产业互联网平台,通过 SaaS 系统 + 供应链服务,帮助酒店集团降低采购成本、提升运营效率。 + +**发展历程**: +| 时间 | 里程碑 | +|------|--------| +| 20XX年 | 公司成立,获得天使轮融资 | +| 20XX年 | 产品上线,首批客户签约 | +| 20XX年 | A轮融资,GMV突破X亿 | +| 20XX年 | 签约X家头部酒店集团 | + +### 3. 痛点与机会 + +**痛点分析框架**: + +``` +行业痛点 +├── 酒店端痛点 +│ ├── 采购成本高、供应商分散 +│ ├── 库存管理粗放 +│ └── 缺乏数字化工具 +├── 供应商端痛点 +│ ├── 获客成本高 +│ ├── 账期长、现金流压力 +│ └── 缺乏稳定渠道 +└── 行业痛点 + ├── 信息不对称 + ├── 交易效率低 + └── 缺乏标准化 +``` + +**市场机会**: +- 政策利好:数字化转型、供应链金融政策 +- 技术成熟:SaaS、移动互联网、大数据 +- 行业变革:酒店行业整合、集团化趋势 + +### 4. 解决方案 + +**产品矩阵**: + +| 产品 | 目标用户 | 核心功能 | 价值 | +|------|----------|----------|------| +| 酒店采购系统 | 酒店采购部 | 在线下单、供应商管理、成本分析 | 降本30% | +| 供应商平台 | 供应商 | 商品管理、订单处理、对账结算 | 获客成本降低50% | +| 集团管控系统 | 酒店集团 | 集采管理、数据分析、合规审计 | 管理效率提升 | +| 换房服务平台 | 企业/个人 | 房间预订、积分兑换 | 差旅成本优化 | + +**技术架构**(简化版): +``` +┌─────────────────────────────────────┐ +│ 业务中台 │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │订单 │ │商品 │ │用户 │ │结算 │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ │ +├─────────────────────────────────────┤ +│ 数据中台 │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │BI │ │算法 │ │数仓 │ │ +│ └─────┘ └─────┘ └─────┘ │ +└─────────────────────────────────────┘ +``` + +### 5. 商业模式 + +**收入模型**: + +| 收入来源 | 说明 | 占比 | +|----------|------|------| +| 交易佣金 | 供应链交易抽佣 (X%) | XX% | +| SaaS 订阅费 | 系统使用年费 | XX% | +| 金融服务 | 供应链金融分润 | XX% | +| 增值服务 | 数据服务、营销服务 | XX% | + +**单位经济模型**(Unit Economics): +| 指标 | 数值 | 说明 | +|------|------|------| +| CAC | ¥XXX | 获客成本 | +| LTV | ¥XXX | 客户生命周期价值 | +| LTV/CAC | X.X | 健康值 >3 | +| 毛利率 | XX% | | +| 回本周期 | X个月 | | + +### 6. 市场规模 + +**TAM/SAM/SOM 分析**: + +``` +┌────────────────────────────────────────┐ +│ TAM: XXX亿 │ +│ (Total Addressable Market) │ +│ ┌────────────────────────────┐ │ +│ │ SAM: XXX亿 │ │ +│ │ (Serviceable Addressable) │ │ +│ │ ┌──────────────────┐ │ │ +│ │ │ SOM: XXX亿 │ │ │ +│ │ │ (Serviceable │ │ │ +│ │ │ Obtainable) │ │ │ +│ │ └──────────────────┘ │ │ +│ └────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +**酒店供应链市场规模**(示例): +- 中国酒店行业规模:约 6000 亿 +- 酒店采购市场(TAM):约 2000 亿 +- 连锁/集团酒店采购(SAM):约 800 亿 +- 可触达市场(SOM):约 100 亿 + +### 7. 竞争分析 + +**竞争格局**: + +| 类型 | 代表企业 | 优势 | 劣势 | +|------|----------|------|------| +| 传统供应商 | XX、YY | 客户关系、线下服务 | 数字化能力弱 | +| SaaS 厂商 | AA、BB | 技术能力强 | 缺乏供应链能力 | +| 互联网平台 | CC、DD | 流量、资金 | 行业理解不深 | +| **我们** | - | 软件+供应链双轮驱动 | - | + +**差异化优势**: +1. **行业 Know-how**:多年酒店供应链经验 +2. **头部资源**:XX家头部集团客户 +3. **创新模式**:换房模式增强粘性 +4. **技术壁垒**:自研系统,快速迭代 + +### 8. 运营数据 + +**关键指标**: + +| 指标 | 2023 | 2024 | 2025E | YoY增长 | +|------|------|------|-------|---------| +| GMV(亿) | X | X | X | XX% | +| 收入(万) | X | X | X | XX% | +| 客户数 | X | X | X | XX% | +| 复购率 | X% | X% | X% | - | + +**增长曲线**(用文字描述,实际 BP 用图表): +- 2022-2023:产品打磨期,种子客户验证 +- 2024:规模扩张期,客户数 X 倍增长 +- 2025:加速增长期,目标 GMV XX亿 + +**标杆客户案例**: +> **XX酒店集团**(X00家酒店) +> - 合作前:采购分散,年采购成本 X 亿 +> - 合作后:统一平台采购,成本降低 XX% +> - 换房模式:年换房 X 万间夜,转化采购额 XX 万 + +### 9. 团队介绍 + +**核心团队**: + +| 姓名 | 职位 | 背景 | +|------|------|------| +| XXX | CEO | XX酒店集团XX年,XX大学MBA | +| XXX | CTO | 原XX公司技术总监,XX年技术经验 | +| XXX | COO | 原XX供应链公司,XX年运营经验 | +| XXX | CFO | 原XX投资,XX年财务经验 | + +**团队优势**: +- 酒店行业背景:平均 X 年行业经验 +- 技术能力:XX 人研发团队 +- 资源网络:覆盖 XX% 头部酒店集团 + +### 10. 融资计划 + +**本轮融资**: + +| 项目 | 内容 | +|------|------| +| 融资轮次 | X 轮 | +| 融资金额 | 人民币 XXX 万元 | +| 出让股份 | XX% | +| 投前估值 | 人民币 XXX 万元 | +| 资金用途 | 见下表 | + +**资金用途**: + +| 用途 | 占比 | 说明 | +|------|------|------| +| 产品研发 | 40% | 核心系统升级、AI 能力建设 | +| 市场拓展 | 30% | 销售团队扩张、客户获取 | +| 运营投入 | 20% | 供应链能力建设、服务体系 | +| 储备资金 | 10% | 现金流储备 | + +**里程碑规划**: + +| 时间 | 目标 | +|------|------| +| 融资后6个月 | 客户数达到 X 家,GMV XX亿 | +| 融资后12个月 | 新产品上线,进入 X 个新区域 | +| 融资后18个月 | 实现盈亏平衡 | + +--- + +## 行业专项模板 + +### 酒店供应链 BP 要点 + +**换房模式说明**: + +``` +换房模式运作逻辑 +───────────────────────────────────────── + +1. 酒店提供房间资源 + 酒店 → 平台:承诺年度换房额度(如100万间夜) + +2. 平台转化为采购额度 + 换房价值 → 按比例转化为供应链采购额度 + +3. 酒店在平台消费 + 酒店使用额度 → 在供应链平台采购商品/服务 + +4. 平台获取佣金 + 供应链交易 → 平台抽取 X% 服务佣金 + +核心价值: +✓ 酒店:用闲置资源换取采购成本优化 +✓ 平台:获得稳定交易额 + 服务佣金 +✓ 供应商:获得优质渠道 + 稳定订单 +``` + +### 供应链平台指标体系 + +| 指标类别 | 指标名称 | 说明 | 行业基准 | +|----------|----------|------|----------| +| 规模指标 | GMV | 平台交易总额 | - | +| | 活跃客户数 | 月活跃采购客户 | - | +| | SKU 数量 | 商品种类数 | - | +| 效率指标 | 订单转化率 | 询价到下单转化 | >15% | +| | 履约率 | 按时交付率 | >95% | +| | 客诉率 | 投诉订单占比 | <1% | +| 财务指标 | 佣金率 | 平台抽佣比例 | 3-8% | +| | 毛利率 | | 30-50% | +| | CAC Payback | 获客成本回收期 | <12月 | + +--- + +## 使用说明 + +### 快速生成 BP + +向我描述你的需求,我会帮你生成商业计划书内容: + +**示例输入**: +> 帮我写商业计划书第5章商业模式部分,我们是酒店供应链平台,主要收入来源是交易佣金和SaaS订阅费,另外还有换房模式 + +**我会输出**: +- 结构化的商业模式描述 +- 收入模型分析 +- 需要补充的数据清单 + +### BP 审核优化 + +你可以提供已有的 BP 内容让我帮你: +- 检查逻辑完整性 +- 优化表达方式 +- 补充行业数据 +- 突出差异化优势 + +--- + +## 输出格式 + +### 支持的输出方式 + +| 方式 | 工具 | 说明 | +|------|------|------| +| Markdown | 直接输出 | 快速预览 | +| 飞书文档 | feishu skill | 在线协作 | +| Word/PDF | docx/pdf skill | 正式文件 | +| PPT | pptx skill | 路演演示 | + +### 融资路演 PPT 结构 + +如需生成融资路演 PPT,建议使用以下结构: + +1. 封面(公司名+Slogan) +2. 问题(行业痛点) +3. 方案(产品价值) +4. 市场(规模机会) +5. 产品(功能展示) +6. 模式(如何赚钱) +7. 数据(业绩证明) +8. 竞争(差异优势) +9. 团队(核心成员) +10. 融资(计划用途) + +--- + +## 注意事项 + +1. **数据真实性**:BP 中的数据必须真实可查证 +2. **保密性**:敏感数据可用 X 代替 +3. **针对性**:根据投资人偏好调整侧重点 +4. **简洁性**:每页一个核心信息点 +5. **视觉化**:用图表代替大段文字 + +--- + +## 与其他技能协同 + +| 技能 | 协同场景 | +|------|----------| +| biz-contract | 融资后签署投资协议 | +| feishu | 将 BP 保存到飞书文档 | +| data-excel | 财务模型和数据分析 | + +--- + +## PPT 设计能力 + +本技能支持生成商业计划书 PPT,输出为 HTML 格式的演示文稿,可直接在浏览器中全屏演示,也可转换为 PDF 或截图使用。 + +### PPT 输出方式 + +当用户要求生成 PPT 时,使用以下方式: + +1. **HTML 演示文稿**(推荐) + - 生成单个 HTML 文件 + - 支持全屏演示(按 F 键) + - 支持键盘翻页(← →) + - 可直接打印为 PDF + - 响应式设计,适配各种屏幕 + +2. **Markdown 演示脚本** + - 输出每页内容的文字稿 + - 用户可自行复制到 PPT 软件 + +### HTML PPT 模板 + +生成 PPT 时使用以下 HTML 模板结构: + +```html + + + + + + 商业计划书 - [公司名称] + + + +
+ + +
+

[公司名称]

+

[一句话 Slogan]

+

[日期] · 商业计划书

+ 1 / N +
+ + +
+

公司定位

+

[一句话定位]

+
+
+
[X]年
+
深耕行业
+
+
+
[X]家
+
头部客户
+
+
+
[X]轮
+
完成融资
+
+
+ 2 / N +
+ + + + + + + + +``` + +### BP PPT 标准页面结构 + +生成商业计划书 PPT 时,按以下结构组织: + +| 页码 | 页面类型 | 内容要点 | 设计建议 | +|------|----------|----------|----------| +| 1 | 封面 | 公司名、Slogan、日期 | 居中大标题,渐变背景 | +| 2 | 公司定位 | 一句话定位 + 核心数据 | 3-4 个数据卡片 | +| 3 | 行业痛点 | 3-4 个核心痛点 | 图标 + 简短描述 | +| 4 | 解决方案 | 产品价值主张 | 流程图或产品矩阵 | +| 5 | 商业模式 | 收入来源、盈利方式 | 模式图 + 收入占比 | +| 6 | 市场规模 | TAM/SAM/SOM | 同心圆图 + 数字 | +| 7 | 竞争分析 | 竞争矩阵、差异化 | 2x2 矩阵图 | +| 8 | 运营数据 | 核心指标、增长趋势 | 4 个大数字卡片 | +| 9 | 客户案例 | 标杆客户、成果 | Logo + 数据对比 | +| 10 | 团队介绍 | 核心成员背景 | 头像 + 简介 | +| 11 | 里程碑 | 发展历程 | 时间线 | +| 12 | 财务预测 | 3 年收入预测 | 柱状图示意 | +| 13 | 战略规划 | 未来规划 | 阶段性目标 | +| 14 | 融资计划 | 金额、用途、联系方式 | 饼图 + 联系信息 | + +### PPT 设计原则 + +1. **一页一重点**:每页只传达一个核心信息 +2. **大字少字**:标题 48px+,正文 24px+,每页文字不超过 50 字 +3. **数据可视化**:用数字卡片、图表代替文字描述 +4. **留白充足**:内容不要铺满,保持呼吸感 +5. **配色统一**:使用 2-3 种主色,保持一致性 +6. **高对比度**:深色背景 + 浅色文字,或浅色背景 + 深色文字 + +### 配色方案 + +**深色科技风**(推荐): +- 背景:#1a1a2e → #16213e(渐变) +- 主色:#667eea(蓝紫色) +- 辅助:#764ba2(紫色) +- 文字:#e2e8f0(浅灰) +- 次要文字:#a0aec0(中灰) + +**浅色商务风**: +- 背景:#ffffff +- 主色:#2196F3(蓝色) +- 辅助:#1976D2(深蓝) +- 文字:#212121(深灰) +- 次要文字:#616161(中灰) + +### 使用示例 + +用户请求: +> 帮我生成商业计划书 PPT + +输出方式: +1. 生成完整的 HTML 文件 +2. 保存到用户指定位置(如桌面、项目目录) +3. 用户用浏览器打开即可演示 + +```bash +# 打开 PPT +open ~/Desktop/bp-presentation.html + +# 或在浏览器中打开后按 F 进入全屏模式 +``` + +### 导出为 PDF + +HTML PPT 可通过浏览器打印功能导出为 PDF: +1. 在浏览器中打开 HTML 文件 +2. 按 Cmd+P (Mac) 或 Ctrl+P (Windows) +3. 选择"另存为 PDF" +4. 设置页面大小为横向 A4 或 16:9 + +### 注意事项 + +1. **内容优先**:先确定 BP 内容,再生成 PPT +2. **数据占位**:敏感数据可用 [X] 占位 +3. **迭代优化**:可分页生成,逐步完善 +4. **本地演示**:HTML 文件完全离线可用,无需网络 diff --git a/plugins/coolbuy-legacy-plugin/.claude-plugin/plugin.json b/plugins/coolbuy-legacy-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..ceb38b7 --- /dev/null +++ b/plugins/coolbuy-legacy-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "coolbuy-legacy-plugin", + "description": "酷采2.0团购管理系统测试与维护。用于酷采2.0系统的功能测试、问题排查、需求验证和对比测试。", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/coolbuy-legacy-plugin/skills/SKILL.md b/plugins/coolbuy-legacy-plugin/skills/SKILL.md new file mode 100644 index 0000000..b159eca --- /dev/null +++ b/plugins/coolbuy-legacy-plugin/skills/SKILL.md @@ -0,0 +1,414 @@ +--- +name: coolbuy-legacy +description: 酷采2.0团购管理系统测试与维护。用于酷采2.0系统的功能测试、问题排查、需求验证和对比测试。当用户提到酷采2.0、百丽、李宁、遗留系统测试相关任务时自动激活。 +--- + +# Coolbuy Legacy (酷采2.0) Skill + +酷采2.0团购管理系统,服务于百丽、李宁等客户的遗留系统,采用 Vue 2 + Element UI + Java Spring 技术栈。 + +## 项目信息 + +| 项目 | 值 | +|------|-----| +| 项目编号 | P264 | +| 项目名称 | 酷采 2.0 | +| AI-Proj 项目ID | 164 | +| 源码路径 | `/Users/donglinlai/workspace/coolbuy-legacy` | +| Git 仓库 | `git@gitea.pipexerp.com:pipexerp/coolbuy-legacy.git` | +| 主分支 | main | +| 技术栈 | Vue 2 + Element UI + Java Spring | + +--- + +## 系统访问 + +### 测试环境 + +| 项目 | 值 | +|------|-----| +| 测试地址 | http://47.105.185.154:9300/login | +| 管理员账号 | 19090009801 | +| 密码 | 123456 | +| 客户账号 | 17761202551 / 202551 | +| 服务器 | 47.105.185.154 | + +### 主要客户 + +- **百丽集团** - 大型鞋业零售集团 +- **李宁体育** - 知名体育用品品牌 + +--- + +## 架构概览 + +``` +coolbuy-legacy/ +├── cool_lining/module-provider/ # Java 后端服务 +│ └── src/main/java/com/jzg/module/ +│ ├── action/ # 控制器层 +│ │ ├── prd/ # 商品模块控制器 +│ │ ├── order/ # 订单模块控制器 +│ │ └── customer/ # 客户模块控制器 +│ ├── dao/model/ # 数据模型 +│ │ ├── prd/ # 商品实体 +│ │ ├── order/ # 订单实体 +│ │ └── customer/ # 客户实体 +│ └── manager/ # 业务逻辑层 +│ ├── prd/ # 商品业务逻辑 +│ ├── order/ # 订单业务逻辑 +│ └── customer/ # 客户业务逻辑 +└── ln_admin/ # Vue 2 前端 + └── src/views2/module/ # 业务模块页面 + ├── prd/ # 商品管理页面 + ├── order/ # 订单管理页面 + └── customer/ # 客户管理页面 +``` + +--- + +## 主要功能模块 + +### 核心业务模块 + +1. **推广方案管理** + - 促销活动配置 + - 折扣规则设置 + - 活动效果统计 + +2. **销售管理** + - 订单处理流程 + - 销售数据统计 + - 客户下单管理 + +3. **草稿单管理** + - 未完成订单保存 + - 草稿单编辑 + - 批量转正式单 + +4. **Y码直客** + - 直客订单管理 + - Y码生成与核销 + - 直客价格体系 + +5. **库存管理** + - 库存查询 + - 库存调拨 + - 库存预警 + +6. **货盘管理** + - 货盘创建 + - 货盘分配 + - 货盘跟踪 + +7. **协同仓管理** + - 多仓协同 + - 仓库调度 + - 发货管理 + +8. **价格管理** + - 商品定价 + - 客户价格体系 + - 最低折扣限制 + +9. **资金管理** + - 账户余额 + - 充值记录 + - 消费明细 + +10. **产品管理** ⭐ + - 商品信息维护 + - SPU/SKU管理 + - 商品分类 + +11. **基础功能** + - 客户管理 + - 用户权限 + - 系统配置 + +12. **数据看板** + - 销售数据分析 + - 库存报表 + - 业务概览 + +13. **公告通知** + - 系统公告 + - 消息推送 + - 通知管理 + +14. **起售数量设置** + - 最小起售量 + - 批量设置 + - 规则配置 + +--- + +## 与酷采3.0的对比 + +### 技术栈差异 + +| 项目 | 酷采2.0 (Legacy) | 酷采3.0 (PaaS) | +|------|------------------|----------------| +| 前端框架 | Vue 2 | React 18 | +| UI组件库 | Element UI | Ant Design | +| 前端构建 | Webpack | Vite | +| 后端语言 | Java | Go | +| 后端框架 | Spring Boot | Gin + go-zero | +| 数据库 | MySQL | PostgreSQL | +| 架构模式 | 单体应用 | 微服务 | +| 部署方式 | 传统部署 | Docker + K8s | + +### 业务差异 + +| 功能 | 酷采2.0 | 酷采3.0 | +|------|---------|---------| +| 多租户 | ❌ 单租户 | ✅ 多租户 SaaS | +| 客户隔离 | 账号级别 | 企业级别 | +| 定制化 | 客户专属部署 | 配置化租户 | +| 扩展性 | 垂直扩展 | 水平扩展 | + +--- + +## 测试任务管理 + +### AI-Proj 项目集成 + +当前项目在 AI-Proj 系统中的ID为 **164**,包含以下测试任务: + +#### 客户最低折扣申请限制功能测试 + +**父任务**: #4725 (in_progress) + +**测试用例**: +- ✅ TC001: 按客户类型配置最低折扣 [P0] - #4726 +- ✅ TC002: 按特定客户配置最低折扣 [P0] - #4727 +- ✅ TC003: 折扣下限输入验证 [P1] - #4728 +- ✅ TC004: 阈值导入模板 [P1] - #4729 +- ✅ TC005: 折扣低于下限 - 拦截 [P0] - #4730 +- ✅ TC006: 折扣等于下限 - 通过 [P0] - #4731 +- ✅ TC007: 折扣高于下限 - 通过 [P1] - #4732 +- 🔄 TC008: 多商品触发限制 - 罗列提示 [P0] - #4733 +- ⏳ TC009: 非一级账号不受限 [P1] - #4734 +- ⏳ TC010: 审核修改折扣低于下限 - 拦截 [P0] - #4735 +- ⏳ TC011: 审核修改折扣等于下限 - 通过 [P0] - #4736 +- ⏳ TC012: 限价标签显示 [P1] - #4737 +- ⏳ TC013: 提示文字验证 [P1] - #4738 + +### 查询任务 + +```javascript +// 获取酷采2.0项目任务列表 +mcp__ai-proj__list_tasks({ projectId: 164 }) + +// 获取特定任务详情 +mcp__ai-proj__get_detailed_task_info({ taskId: 4725 }) + +// 获取任务文档 +mcp__ai-proj__get_task_document({ taskId: 4725 }) +``` + +--- + +## Chrome DevTools 浏览器自动化 + +### 启动调试模式 + +```bash +# macOS +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 \ + --user-data-dir=/tmp/chrome-debug +``` + +### 验证连接 + +```bash +curl http://127.0.0.1:9222/json/version +``` + +### 验证码处理规则 + +**重要**: 当访问需要验证码的系统时: + +1. **不要**使用脚本截图方式获取验证码(验证码会快速过期) +2. **直接提醒用户**: + - 请在浏览器中输入验证码 + - 或请帮我点击登录按钮 +3. 用户操作完成后再继续自动化流程 + +示例提示: +``` +浏览器已打开登录页面,验证码需要手动输入。请在 Chrome 浏览器中: +1. 输入验证码 +2. 点击登录按钮 +完成后告诉我,我将继续后续操作。 +``` + +### 常用 MCP 操作 + +```javascript +// 列出所有页面 +mcp__chrome-devtools__list_pages() + +// 选择页面 +mcp__chrome-devtools__select_page({ pageId: 1 }) + +// 导航到URL +mcp__chrome-devtools__navigate_page({ + type: "url", + url: "http://47.105.185.154:9300/login" +}) + +// 截图 +mcp__chrome-devtools__take_screenshot({ + format: "png", + filePath: "/tmp/screenshot.png" +}) + +// 获取页面快照 +mcp__chrome-devtools__take_snapshot() + +// 点击元素 +mcp__chrome-devtools__click({ uid: "element_uid" }) + +// 填写表单 +mcp__chrome-devtools__fill({ + uid: "input_uid", + value: "text" +}) + +// 执行JavaScript +mcp__chrome-devtools__evaluate_script({ + function: "() => { return document.title; }" +}) +``` + +--- + +## 常见测试场景 + +### 1. 客户最低折扣测试 + +**测试步骤**: +1. 登录管理员账号 (19090009801) +2. 进入价格管理 → 最低折扣配置 +3. 配置客户类型或特定客户的最低折扣 +4. 使用客户账号 (17761202551) 登录 +5. 创建订单,测试折扣限制规则 + +**验证点**: +- 折扣低于下限时系统拦截 +- 折扣等于或高于下限时通过 +- 多商品触发时正确罗列提示 +- 非一级账号不受限制 + +### 2. 订单流程测试 + +**测试步骤**: +1. 客户账号登录 +2. 选择商品加入购物车 +3. 提交订单 +4. 审核订单 (管理员) +5. 发货处理 +6. 订单完成 + +### 3. 库存管理测试 + +**测试步骤**: +1. 查询库存 +2. 创建库存调拨单 +3. 审核调拨单 +4. 确认入库 +5. 验证库存变化 + +--- + +## 需求对比测试流程 + +当酷采3.0实现新功能时,需要与酷采2.0进行对比: + +### 测试流程 + +1. **功能分析** + - 在酷采2.0中找到对应功能 + - 记录现有实现方式 + - 识别差异点 + +2. **页面对比** + - 截图酷采2.0界面 + - 对比UI/UX差异 + - 记录交互流程 + +3. **数据对比** + - 对比数据模型 + - 验证业务规则 + - 确认边界条件 + +4. **性能对比** + - 记录响应时间 + - 对比并发能力 + - 评估用户体验 + +--- + +## 问题排查 + +### 常见问题 + +1. **登录失败** + - 检查账号密码是否正确 + - 验证码是否过期 + - 网络连接是否正常 + +2. **页面加载慢** + - 检查网络状况 + - 清除浏览器缓存 + - 查看服务器日志 + +3. **数据不同步** + - 刷新页面 + - 检查数据库连接 + - 查看后端日志 + +### 日志查看 + +```bash +# SSH到测试服务器 +ssh root@47.105.185.154 + +# 查看应用日志 +tail -f /path/to/coolbuy/logs/application.log + +# 查看错误日志 +tail -f /path/to/coolbuy/logs/error.log +``` + +--- + +## 相关技能 + +- `coolbuy-paas` - 酷采3.0 SaaS租户端开发 +- `coolbuy-platform` - 酷采3.0平台管理端 +- `dev-test` - 软件测试技能 +- `req` - 需求管理技能 +- `siyuan` - 思源笔记(含酷采相关文档) + +--- + +## 版本历史 + +| 版本 | 日期 | 变更 | +|------|------|------| +| 1.0.0 | 2026-01-21 | 初始版本,创建酷采2.0独立技能 | + +--- + +## 注意事项 + +⚠️ **重要提醒**: + +1. 酷采2.0为遗留系统,主要用于参考和对比测试 +2. 新功能开发应在酷采3.0 (coolbuy-paas) 中进行 +3. 测试环境数据仅供测试使用,请勿在生产环境操作 +4. 百丽、李宁等客户仍在使用此系统,测试时注意不要影响生产数据 +5. 发现问题及时记录到 AI-Proj 系统中 diff --git a/plugins/coolbuy-paas-plugin/.claude-plugin/plugin.json b/plugins/coolbuy-paas-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..27e90c3 --- /dev/null +++ b/plugins/coolbuy-paas-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "coolbuy-paas-plugin", + "description": "酷采3.0 SaaS 租户端开发与测试。用于商品管理、订单管理等业务模块开发,以及酷采2.0系统对比测试。", + "version": "1.3.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/coolbuy-paas-plugin/skills/SKILL.md b/plugins/coolbuy-paas-plugin/skills/SKILL.md new file mode 100644 index 0000000..e0b0c1c --- /dev/null +++ b/plugins/coolbuy-paas-plugin/skills/SKILL.md @@ -0,0 +1,803 @@ +--- +name: coolbuy-paas +description: 酷采3.0 SaaS 租户端开发与测试。用于商品管理、订单管理等业务模块开发,以及酷采2.0系统对比测试。当用户提到酷采、coolbuy-paas、商品管理、产品管理、酷采2.0测试相关任务时自动激活。 +--- + +# Coolbuy PaaS Skill + +酷采3.0 SaaS 多租户业务系统,采用 Go + React 技术栈。 + +## 项目信息 + +| 项目 | 值 | +|------|-----| +| 本地路径 | `/Users/donglinlai/coding/qiudl/coolbuy-paas` | +| 工作区路径 | `/Users/donglinlai/workspace/coolbuy-paas` | +| Git 仓库 | `git@gitea.pipexerp.com:pipexerp/coolbuy-paas.git` | +| 主分支 | main | +| 技术栈 | Go (Gin+GORM) + React 18 (Vite+TypeScript+Ant Design) | + +--- + +## 架构概览 + +``` +coolbuy-paas/ +├── erp-service/ # Go 后端 - ERP业务服务 (port 7091) +│ ├── api/ # API 定义 (go-zero) +│ ├── internal/ +│ │ ├── product/ # 商品模块 +│ │ ├── order/ # 订单模块 +│ │ └── inventory/ # 库存模块 +│ └── configs/ +├── foundation-service/ # 基础服务 (port 7090) +├── auth-service/ # 认证服务 (port 7089) +├── ai-service/ # AI 服务 (port 7092) - AI Chat, Tool Calling +│ ├── api/ +│ │ └── etc/ # 配置文件 (多环境) +│ └── services/ +│ ├── chat_service.go # 对话服务 +│ └── ai_ticket_reporter.go # AI 工单上报 +├── gateway/ # API 网关 +├── web/ # React 前端 - 租户端 (port 4000) +│ ├── src/ +│ │ ├── services/ # API 服务 +│ │ │ ├── aiChatApi.ts +│ │ │ └── chatSessionService.ts +│ │ └── components/AIChat/ # AI 聊天组件 +│ └── .env.* # 环境配置 +└── web-mall/ # React 前端 - 独立商城 (port 5174) + ├── src/ + │ ├── pages/ # 首页/分类/商品/购物车/结算/个人中心 + │ ├── api/ # API 层(共享 erp-service) + │ ├── auth/ # 认证(共享 auth-service JWT) + │ ├── store/ # Zustand 状态管理 + │ └── layouts/ # 商城布局(顶部导航,非侧边栏) + ├── nginx.conf # 生产 Nginx 配置 + └── Dockerfile # 多阶段构建 +``` + +--- + +## 多环境配置 + +### 运行环境 + +| 环境 | 前端配置 | 后端配置 | 说明 | +|------|---------|---------|------| +| development | `.env.development` | `ai-api.yaml` | 本地开发 | +| staging | `.env.staging` | `ai-api.staging.yaml` | 预发布环境 | +| production | `.env.production` | `ai-api.production.yaml` | 生产环境 | + +### 核心环境变量 + +| 变量 | 说明 | 开发环境示例 | +|------|------|------------| +| `VITE_AUTH_SERVICE_URL` | 认证服务 | `http://127.0.0.1:7089` | +| `VITE_FOUNDATION_SERVICE_URL` | 基础服务 | `http://127.0.0.1:7090` | +| `VITE_BUSINESS_SERVICE_URL` | ERP服务 | `http://127.0.0.1:7091` | +| `VITE_AI_SERVICE_URL` | AI服务 | `http://127.0.0.1:7092` | +| `VITE_AI_PROJ_URL` | ai-proj服务 | `http://127.0.0.1:8080` | + +### Vite 代理配置 (vite.config.ts) + +```typescript +proxy: { + '/api/v1/auth': { target: VITE_AUTH_SERVICE_URL }, + '/api/v1/ai': { target: VITE_AI_SERVICE_URL }, // AI Chat + '/api/v1/chat': { target: VITE_AI_PROJ_URL }, // Chat Sessions + '/api/v1': { target: VITE_BUSINESS_SERVICE_URL }, // 默认路由 +} +``` + +--- + +## AI 服务集成 + +### AI Chat 功能 + +| 端点 | 服务 | 说明 | +|------|------|------| +| `/api/v1/ai/chat/stream` | ai-service (7092) | 流式对话 | +| `/api/v1/ai/tools` | ai-service (7092) | 获取可用工具 | +| `/api/v1/chat/sessions` | ai-proj (8080) | 会话管理 | +| `/api/v1/ai-tickets` | ai-proj (8080) | AI 工单 | + +### AI Chat 代码路径(重要) + +**前端实际调用链**: +``` +useChat.ts + → import * as aiChatService from '@/services/aiChatApi' ← 实际使用的文件! + → aiChatApi.sendChatMessageStream() + → aiChatApi.sendStreamingChatRequest() ← 用 fetch() 直接发 SSE 请求 +``` + +**注意**:`@/services/aiChatService.ts` 是遗留文件,**未被使用**。修改 AI Chat 前端逻辑时必须修改 `aiChatApi.ts`。 + +**流式请求绕过 axios**:`aiChatApi.ts` 使用原生 `fetch()` 发送 SSE 请求,不经过 `request.ts` 的 axios 拦截器。因此 `Authorization`、`X-Tenant-ID` 等 header 需要在 `fetch()` 中手动添加。 + +### AI Chat 认证链路 + +``` +前端登录 → auth-service 返回 JWT (含 tenant_id, username, roles) + → localStorage 存储 user_info + token + → AI Chat 发送请求时: + 1. getToken() 获取 JWT + 2. getTenantIdFromContext() 获取 tenant_id + 3. fetch() 携带 Authorization + X-Tenant-ID header + → ai-service auth middleware 解析 JWT claims + → Go context 注入 user_id, username, tenant_id, tenant_code, roles + → buildUserIdentityContext(ctx) 提取并追加到 system prompt + → AI 能回答"我的租户ID是什么"等问题 +``` + +**关键文件**: +| 文件 | 作用 | +|------|------| +| `web/src/services/aiChatApi.ts` | 前端 AI Chat API(实际使用) | +| `web/src/hooks/useChat.ts` | Chat 状态管理 Hook | +| `ai-service/api/internal/middleware/auth_middleware.go` | JWT 解析,注入 context | +| `ai-service/services/chat_service.go` | 对话服务,system prompt 注入 | +| `ai-service/services/context_builder.go` | 用户上下文构建 | + +**JWT 密钥一致性**:auth-service 和 ai-service 的 `JWT.AccessSecret` 必须一致,否则 ai-service 无法验证 token(开发环境有 flexible parse 兜底)。 + +### AI 工单上报 + +ai-service 的错误会自动上报到 ai-proj 的 AI 工单系统: + +```yaml +# ai-service/api/etc/ai-api.yaml +AITicketReporter: + Enabled: true + AiProjURL: "http://localhost:8080" +``` + +错误类型自动分类: +- `api_error` - API 调用错误 +- `missing_tool` - 工具调用错误 +- `timeout` - 超时错误 +- `permission_denied` - 权限错误 + +--- + +## 酷采2.0 测试环境 (Legacy) + +用于参考和对比酷采3.0开发。 + +### 访问信息 + +| 项目 | 值 | +|------|-----| +| 测试地址 | http://47.105.185.154:9300/login | +| 管理员账号 | 19090009801 | +| 密码 | 123456 | +| 客户账号 | 17761202551 / 202551 | +| 技术栈 | Vue 2 + Element UI + Java Spring | + +### 主要功能模块 + +- 推广方案、销售管理、草稿单管理 +- Y码直客、库存管理、货盘管理 +- 协同仓、价格管理、资金管理 +- 基础功能、数据看板、公告通知 +- 起售数量设置、**产品管理** + +### 源码位置 + +``` +~/workspace/coolbuy-legacy/ +├── cool_lining/module-provider/ # Java 后端 +│ └── src/main/java/com/jzg/module/ +│ ├── action/prd/ # 商品控制器 +│ ├── dao/model/prd/ # 商品实体 +│ └── manager/prd/ # 商品业务逻辑 +└── ln_admin/ # Vue 前端 + └── src/views2/module/prd/ # 商品管理页面 +``` + +--- + +## Chrome DevTools 浏览器自动化 + +用于系统测试、截图、UI验证。 + +### 启动 Chrome 调试模式 + +```bash +# macOS +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 \ + --user-data-dir=/tmp/chrome-debug +``` + +### 验证连接 + +```bash +curl http://127.0.0.1:9222/json/version +``` + +### 验证码处理规则 + +**重要**: 当访问需要验证码的系统时: + +1. **不要**使用脚本截图方式获取验证码(验证码会快速过期) +2. **直接提醒用户**: + - 请在浏览器中输入验证码 + - 或请帮我点击登录按钮 +3. 用户操作完成后再继续自动化流程 + +示例提示: +``` +浏览器已打开登录页面,验证码需要手动输入。请在 Chrome 浏览器中: +1. 输入验证码 +2. 点击登录按钮 +完成后告诉我,我将继续后续操作。 +``` + +### 常用 CDP 操作 + +```javascript +// 截图 +Page.captureScreenshot({ format: 'png' }) + +// 导航 +Page.navigate({ url: 'http://...' }) + +// 执行 JS +Runtime.evaluate({ expression: '...' }) + +// 点击元素 +Runtime.evaluate({ + expression: `document.querySelector('button').click()` +}) +``` + +--- + +## 酷采3.0 PRD 参考 + +PRD 文档存储在 ai-proj 任务系统: + +| 模块 | 任务ID | 文档名称 | +|------|--------|----------| +| 商品管理 | #4754 | 酷采3.0商品管理模块 | +| 商品基本信息 | #4770 | 商品基本信息管理模块PRD | + +获取 PRD 文档: +``` +mcp__ai-proj__get_task_document(taskId: 4770) +``` + +--- + +## 本地开发 + +### 启动服务 + +```bash +# ERP 服务 (port 7091) +cd erp-service && go run api/main.go -f configs/config.local.yaml + +# AI 服务 (port 7092) +cd ai-service && go run api/ai.go -f api/etc/ai-api.yaml + +# 租户端前端 (port 4000) +cd web && npm run dev + +# 独立商城前端 (port 5174) +cd web-mall && npm run dev +``` + +### 依赖服务 + +| 服务 | 端口 | 说明 | +|------|------|------| +| PostgreSQL | 5432 | 本地数据库 | +| Redis | 6379 | 缓存 | +| ai-proj | 8080 | AI 项目管理(可选,用于 Chat Sessions) | + +### 本地数据库 + +| 项目 | 值 | +|------|-----| +| 类型 | PostgreSQL | +| Database | paas_foundation | +| User | coolbuy-dev | +| Password | (空) | + +### 远程数据库(生产参考) + +| 项目 | 值 | +|------|-----| +| Host | 192.144.137.14 | +| Database | coolbuy_paas | +| User | platform | +| Password | Zhiyuncai2025~ | + +### 数据库管理工具 - TablePlus + +**推荐工具**: TablePlus - macOS 原生数据库 GUI 客户端,支持 PostgreSQL/MySQL/Redis 等。 + +#### 安装 TablePlus + +```bash +# 使用 Homebrew 安装 +brew install --cask tableplus + +# 启动 TablePlus +open -a TablePlus +``` + +#### 自动配置数据库连接 + +TablePlus 的连接配置存储在 `~/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist`。 + +**快速配置脚本**(已包含本地和远程连接): + +```bash +# 1. 关闭 TablePlus(如果正在运行) +killall TablePlus 2>/dev/null || true + +# 2. 备份现有配置 +cp ~/Library/Application\ Support/com.tinyapp.TablePlus/Data/Connections.plist \ + ~/Library/Application\ Support/com.tinyapp.TablePlus/Data/Connections.plist.backup + +# 3. 编辑配置文件添加连接(参考下方 plist 格式) + +# 4. 重启 TablePlus +open -a TablePlus +``` + +**连接配置示例**(plist 格式): + +```xml + + + ConnectionName + Coolbuy PaaS - Local (开发环境) + DatabaseHost + localhost + DatabasePort + 5432 + DatabaseName + paas_foundation + DatabaseUser + coolbuy-dev + DatabasePasswordMode + 0 + Driver + PostgreSQL + Enviroment + local + statusColor + #3B82F6 + +``` + +#### 手动配置步骤 + +如果自动配置失败,可以手动在 TablePlus GUI 中添加: + +1. 打开 TablePlus → 点击 "Create a new connection" +2. 选择 PostgreSQL +3. 填写连接信息: + +**本地连接**: +- Name: `Coolbuy PaaS - Local (开发环境)` +- Host: `localhost` +- Port: `5432` +- Database: `paas_foundation` +- User: `coolbuy-dev` +- Password: (留空) +- Color: 蓝色 + +**生产连接**: +- Name: `Coolbuy PaaS - Production (生产环境)` +- Host: `192.144.137.14` +- Port: `5432` +- Database: `coolbuy_paas` +- User: `platform` +- Password: `Zhiyuncai2025~` +- Color: 红色(警示) +- **重要**:勾选 "Read-only mode" 防止误操作 + +#### 验证连接 + +连接成功后运行以下 SQL 验证: + +```sql +-- 检查数据库版本 +SELECT version(); + +-- 查看所有表 +SELECT table_schema, table_name +FROM information_schema.tables +WHERE table_schema NOT IN ('pg_catalog', 'information_schema') +ORDER BY table_schema, table_name; + +-- 查看库存预占记录 +SELECT * FROM biz_inventory_reservations +ORDER BY created_at DESC +LIMIT 10; + +-- 查看活跃租户 +SELECT id, name, code, status +FROM fnd_tenants +WHERE status = 1 AND deleted_at IS NULL; +``` + +#### TablePlus 常用快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Cmd + N` | 新建连接 | +| `Cmd + T` | 新建 Tab | +| `Cmd + K` | 连接列表 | +| `Cmd + R` | 刷新 | +| `Cmd + /` | 注释/取消注释 SQL | +| `Cmd + Enter` | 执行 SQL | +| `Cmd + Shift + E` | 导出数据 | + +#### 安全提示 + +⚠️ **生产数据库操作注意事项**: + +1. **必须**启用 "Read-only mode"(连接设置中) +2. **避免**直接执行 UPDATE/DELETE 操作 +3. **建议**使用事务测试:`BEGIN; ... ROLLBACK;` +4. **慎用**批量操作 +5. **定期**备份重要数据 + +--- + +## 独立商城 (web-mall) + +独立 B2B 商城前端,与租户端 `web/` 并行运行,共享后端 API。 + +### 定位对比 + +| 维度 | 租户端 (web/) | 独立商城 (web-mall/) | +|------|--------------|---------------------| +| 定位 | 内部采购经理 | 终端采购用户 | +| 布局 | ERP 侧边栏 | 商城顶部导航 | +| 选品 | 表单弹窗选择 | 浏览分类/搜索+加购 | +| 下单 | ERP 表单模式 | 购物车→结算 | + +### 页面结构 + +| 页面 | 路径 | 说明 | +|------|------|------| +| 首页 | `/` | 推荐商品、分类入口 | +| 商品列表 | `/products` | 搜索、筛选、排序 | +| 商品详情 | `/products/:id` | SKU 选择、加入购物车 | +| 购物车 | `/cart` | 购物车管理 | +| 结算 | `/checkout` | 提交订单 | +| 个人中心 | `/user` | 订单列表、订单详情 | + +### Vite 代理配置 (web-mall/vite.config.ts) + +```typescript +proxy: { + '/api/v1/auth': { target: 'http://localhost:7089' }, // auth-service + '/api': { target: 'http://localhost:7091' }, // erp-service +} +``` + +### Docker 部署 + +| 环境 | 端口 | 镜像 | +|------|------|------| +| 开发 | 5174 (Vite) | 本地 `npm run dev` | +| 生产 | 8889:80 | `saltthing123/coolbuy-paas-web-mall` | + +### 需求来源 + +REQ-20260214-0001(采购单双模式),PRD 任务 #5874。 + +--- + +## 商品模块开发 + +### 前端页面结构 + +``` +web/src/pages/Product/ +├── ProductList/ # 商品列表页 +│ ├── index.tsx +│ ├── columns.tsx # 表格列配置 +│ └── searchFields.ts # 搜索表单配置 +├── ProductForm/ # 商品表单页(新增/编辑) +│ ├── index.tsx +│ ├── BasicInfo.tsx # 基本信息 +│ ├── ImageUpload.tsx # 图片上传 +│ └── PriceInfo.tsx # 价格信息 +└── ProductDetail/ # 商品详情弹窗 + └── index.tsx +``` + +### 后端模块结构 + +``` +erp-service/internal/product/ +├── model/ +│ ├── spu.go # SPU 模型 +│ └── sku.go # SKU 模型 +├── biz/ +│ ├── spu.go # SPU 业务逻辑 +│ └── sku.go # SKU 业务逻辑 +├── store/ +│ ├── spu.go # SPU 数据访问 +│ └── sku.go # SKU 数据访问 +└── handler/ + └── spu_handler.go # HTTP 处理器 +``` + +### API 端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/products | 商品列表 | +| GET | /api/v1/products/:id | 商品详情 | +| POST | /api/v1/products | 创建商品 | +| PUT | /api/v1/products/:id | 更新商品 | +| DELETE | /api/v1/products/:id | 删除商品 | +| PATCH | /api/v1/products/:id/status | 上架/下架 | + +--- + +## 研发经验与开发策略 + +> 本章总结了酷采 3.0 开发过程中积累的研发方法论,指导 Claude Code 高效完成业务模块开发。 + +### 系统定位 + +酷采 3.0 是酷采 2.0 的**重构升级版本**,不是 1:1 复刻。核心策略:**用 2.0 的业务逻辑 + 3.0 的代码模式**。 + +### 两代系统架构对比 + +| 维度 | 酷采 2.0 (coolbuy-legacy) | 酷采 3.0 (coolbuy-paas) | +|------|--------------------------|------------------------| +| 后端 | Java Spring Boot 单体 | Go 微服务 (5 个独立服务) | +| 前端 | Vue 2 + Element UI | React 18 + Ant Design 5 + Vite | +| 数据库 | MySQL + MyBatis | PostgreSQL + GORM | +| 认证 | Shiro | JWT + Redis 黑名单 | +| 多租户 | company_id 字段 | 原生 SaaS tenant 隔离 | +| 权限 | 多表关联 RBAC | 权限引擎 + 数据权限 | + +### 业务模块完成度 + +| 业务模块 | 2.0 页面 | 3.0 后端 | 3.0 前端 | 优先级 | +|----------|---------|---------|---------|--------| +| 系统管理 (用户/角色/权限/组织) | 9 页 | 95% | 95% | - 已完成 | +| 商品管理 (SPU/SKU/分类/品牌) | 18 页 | 90% | 85% | P1 补齐 | +| 订单管理 (销售/采购/退货/审批) | 22 页 | 85% | 70% | **P0** | +| 仓储管理 (入库/出库/库存/物流) | 6 页 | 70% | 50% | P0 | +| 客户管理 (等级/销售员/供应商) | 17 页 | 60% | 50% | P0 | +| 财务结算 (账户/流水/结算单) | 3 页 | 40% | 30% | P1 | +| 报备授权 (项目/渠道/审核) | 20+ 页 | 20% | 10% | P2 评估 | +| 分销网络 (3.0 新增) | 无 | 75% | 75% | P1 | +| 购物车 & 商城 (web-mall) | 无 | 80% | 80% | P1 | + +### 三层工作流 (需求 → 代码) + +``` +第一层: 需求 → PRD (/req-prd skill) + 输入: 2.0 对应页面 + 业务规则描述 + 输出: PRD 文档 + 原型 HTML (dev-plans/prototypes/) + 工具: /req create → /req doc + +第二层: PRD → 开发计划 (/req-dev skill) + 输入: PRD + 当前 3.0 代码库分析 + 输出: 文件级开发任务拆分 + 工具: /req doc → ai-proj tasks + +第三层: 任务 → 代码 (/dev-coding skill) + 输入: 开发计划 + 参考模块代码 + 输出: 可运行的前后端代码 + 工具: Claude Code 直接编码 +``` + +### 模块复制模式 (核心开发方法) + +每个新业务模块参照已有成熟模块的代码模式生成: + +``` +参考模块 (User) 目标模块 (Order) +──────────────────── ──────────────────── +api/types.ts (追加接口) → OrderQueryParams, Order, OrderItem... +api/order.ts (新建) → getOrderList, getOrderById, createOrder... +pages/OrderList.tsx → 统计卡片 + 搜索表单 + 工具栏 + 表格 +pages/OrderDetail.tsx → 头部信息 + 多 Tab (基本信息/明细/日志) +pages/OrderForm.tsx → Modal 表单 (新增/编辑) +router/tenantRoutes.tsx → 追加路由注册 +``` + +**关键约定**(必须遵循): +- API 字段使用 `snake_case` (如 `order_status`, `created_at`) +- 响应数据解包:`res.data?.data || res.data`(双层嵌套兼容) +- 分页参数:`{ current, pageSize }` → 后端映射为 `{ page, page_size }` +- 时间格式化:`dayjs(v).format('YYYY-MM-DD HH:mm:ss')` +- 枚举渲染:`{labelMap[value]}` +- 表格滚动:`scroll={{ x: 列宽总和 }}` +- 权限控制:`` 包裹按钮 + +### 单模块开发 SOP + +| 步骤 | 产出 | 预估时间 | +|------|------|---------| +| 1. 类型定义 | `types.ts` 追加接口/枚举 | 10 min | +| 2. API 层 | `api/xxx.ts` (CRUD + 业务接口) | 10 min | +| 3. 列表页 | `XxxList.tsx` (搜索+表格+工具栏+统计) | 20 min | +| 4. 详情页 | `XxxDetail.tsx` (多 Tab 信息展示) | 20 min | +| 5. 表单页 | `XxxForm.tsx` (新增/编辑 Modal) | 15 min | +| 6. 路由注册 | `tenantRoutes.tsx` 追加 lazy import | 5 min | +| 7. 联调修复 | 对接实际 API 后的字段/格式调整 | 30 min | + +**单模块总计约 2 小时**,相比纯手写 2-3 天。 + +### 参考 2.0 代码的方法 + +开发新模块时,Claude 应按以下顺序获取业务知识: + +```bash +# 1. 读 2.0 前端路由 → 了解页面结构和导航 +~/workspace/coolbuy-paas/coolbuy-legacy/ln_admin/src/router/_routers.js + +# 2. 读 2.0 前端页面 → 了解字段、搜索条件、操作按钮 +~/workspace/coolbuy-paas/coolbuy-legacy/ln_admin/src/views/module// + +# 3. 读 2.0 后端 Manager → 了解业务规则和校验逻辑 +~/workspace/coolbuy-paas/coolbuy-legacy/cool_belle/module-provider/src/main/java/com/jzg/module/manager// + +# 4. 读 2.0 数据模型 → 了解表结构和字段 +~/workspace/coolbuy-paas/coolbuy-legacy/cool_belle/module-provider/src/main/java/com/jzg/module/dao/model// + +# 5. 读 3.0 已有参考模块 → 了解代码模式 +~/coding/qiudl/coolbuy-paas/web/src/modules/foundation/pages/User/ +~/coding/qiudl/coolbuy-paas/erp-service/internal// +``` + +### 联调经验总结 + +以下是已验证的联调常见问题和修复模式: + +| 问题 | 原因 | 修复模式 | +|------|------|---------| +| 表格空数据 | API 响应格式 `{list,total}` vs `data[]` | `const d = res.data?.data \|\| res.data; setList(d?.list \|\| d)` | +| 按钮不显示 | Permission 组件依赖 `hasPermission()` | 确保 `isSuperAdmin()` 包含 `admin` 角色 | +| 时间显示原始 ISO | 缺少 dayjs 格式化 | 统一用 `formatTime()` helper | +| 统计接口 404 | 后端未部署 | `catch {}` 静默处理,不弹错误 | +| "加载更多"覆盖数据 | setState 替换而非追加 | 加 `append` 参数:`prev => append ? [...prev, ...newList] : newList` | +| TreeSelect undefined | 组织树数据映射缺字段 | 确保 `convertTreeData` 映射 `title/value/key` | +| TS 类型不匹配 | API 返回结构与类型定义不一致 | 用 `: any` 断言,后续补齐类型 | +| antd deprecated | v5.26 废弃 API | `bordered={false}` → `variant="borderless"` | + +### 分阶段推进计划 + +**Phase 1 — 核心交易闭环** (决定系统可用性) +- 订单管理:后端 handler 已有 507 行,前端 20 页面待联调 +- 客户管理:基础 CRUD + 客户等级 + 销售员体系 +- 仓储管理:入库/出库/库存查询 + +**Phase 2 — 商品完善 + 财务** +- 商品模块补齐:组合方案、现货加标、生效时间管理 +- 财务结算:账户管理、流水查询、结算单 + +**Phase 3 — 运营支撑** +- 报备/授权模块(评估后选择性迁移,2.0 有 20+ 页面) +- 报表/数据分析 +- 小程序/H5 商城增强 + +### 2.0 订单模块业务知识 (已梳理) + +#### 订单状态机 + +``` +创建 → 待支付(01) → 已支付(02) → 已发货(03) → 已收货(04) → 已完成(10) + ↘ 已取消(05) ↘ 已退款(08) + 待审核(06) 已关闭(07) + 待出库(11) +``` + +#### 支付方式 + +| 代码 | 方式 | 业务规则 | +|------|------|---------| +| 01 | 预存款 | 扣减预付账户余额,记 BizFundLog | +| 02 | 授信 | 扣减信用额度,需审核 | +| 11 | 银行转账 | 自动生成付款单(MallPay)+收款单(MallReceive) | +| 13 | 货到付款 | 无需预付 | + +#### 退款类型 + +| 类型 | 说明 | 校验规则 | +|------|------|---------| +| 整单退(01) | 全额退款 | 同一用户/订单只能有一个待处理退款 | +| 按数量退(02) | 部分数量退 | 检查可退数量 | +| 按物品退(03) | 指定商品退 | 逐项检查 | +| 退运费(04) | 仅退运费 | 运费大于 0 | + +#### 用户角色与操作权限 + +| userType | 身份 | 可执行操作 | +|----------|------|-----------| +| 01 | 平台管理员 | 直接取消、审核、改价、所有操作 | +| 02 | 供应商 | 申请退款、查看、发货 | +| 03 | 客户 | 申请退款、查看、收货确认 | +| 04 | 前端用户 | 下单、查看、基础操作 | + +### 3.0 订单模块现状 (已梳理) + +#### 后端 API (erp-service) + +``` +GET /api/v1/orders # 列表 (支持 OrderNo/Status/Date/Amount 筛选) +GET /api/v1/orders/:id # 详情 (含 items) +POST /api/v1/orders # 创建 (含采购权限/区域/MOQ 校验) +PUT /api/v1/orders/:id # 更新 (仅草稿) +DELETE /api/v1/orders/:id # 删除 (仅草稿) +POST /api/v1/orders/:id/submit-approval # 提交审批 +POST /api/v1/orders/:id/approve # 审批通过/拒绝 +POST /api/v1/orders/:id/confirm-payment # 确认付款 +POST /api/v1/orders/:id/ship # 发货 +POST /api/v1/orders/:id/confirm-delivery # 确认收货 +POST /api/v1/orders/:id/complete # 完成 +POST /api/v1/orders/:id/cancel # 取消 +POST /api/v1/orders/:id/refund # 退款 +GET /api/v1/orders/:id/status-logs # 状态变更日志 +GET /api/v1/orders/:id/approval-history # 审批历史 +GET /api/v1/orders/statistics/* # 统计 (6 个子接口) +``` + +#### 3.0 订单状态 (14 态,比 2.0 更细) + +``` +Draft(10) → PendingApproval(20) → Approved(29) → PendingPayment(30) → Paid(39) +→ PendingShipment(40) → PartialShipped(45) → Shipped(49) +→ PartialDelivered(55) → Delivered(59) → Completed(90) +→ Rejected(92) / Cancelled(95) / Refunded(97) +``` + +#### 前端已有页面 (20 个) + +``` +/order/sales - SalesOrderList +/order/sales/add - SalesOrderForm +/order/business - BusinessOrderList +/order/purchase - PurchaseOrderList +/order/purchase/add - PurchaseOrderForm +/order/purchase/:id - PurchaseOrderDetail +/order/pending-approval - PendingApprovalList +/order/return - ReturnOrderList +/order/return/create - CreateReturnOrder +/order/return/:id - ReturnOrderDetail +/order/statistics - PurchaseOrderStatistics +/checkout - CheckoutPage +``` + +--- + +## 相关技能 + +- `coolbuy-platform` - 平台管理端开发 +- `coolbuy-legacy` - 酷采 2.0 测试与参考 +- `dev-coding` - 软件编码开发 +- `dev-arch` - 软件架构设计 +- `dev-test` - 软件测试 +- `req-prd` - 产品需求文档编写 +- `req-dev` - 需求开发计划编写 +- `siyuan` - 思源笔记(含酷采相关文档) + +--- + +## 版本历史 + +| 版本 | 日期 | 变更 | +|------|------|------| +| 1.4.0 | 2026-02-25 | 新增研发经验与开发策略章节:两代系统对比、模块完成度、三层工作流、模块复制模式、联调经验、订单模块业务梳理 | +| 1.3.0 | 2026-02-14 | 新增数据库管理工具章节:TablePlus 安装配置、自动配置脚本、连接验证、安全提示 | +| 1.2.0 | 2026-02-14 | 新增 web-mall 独立商城模块(架构、页面、代理、部署端口) | +| 1.1.0 | 2026-02-13 | 新增 AI Chat 代码路径、认证链路、关键文件说明;记录 aiChatApi vs aiChatService 陷阱 | +| 1.0.0 | 2026-01-10 | 初始版本,添加酷采2.0测试环境和浏览器自动化指南 | diff --git a/plugins/coolbuy-platform-plugin/.claude-plugin/plugin.json b/plugins/coolbuy-platform-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..2508866 --- /dev/null +++ b/plugins/coolbuy-platform-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "coolbuy-platform-plugin", + "description": "Coolbuy SaaS 平台管理端开发与部署。用于平台端前后端开发、租户管理、部署发布、翻译检查等任务。", + "version": "1.0.9", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/coolbuy-platform-plugin/skills/SKILL.md b/plugins/coolbuy-platform-plugin/skills/SKILL.md new file mode 100644 index 0000000..fe714c2 --- /dev/null +++ b/plugins/coolbuy-platform-plugin/skills/SKILL.md @@ -0,0 +1,338 @@ +--- +name: coolbuy-platform +description: Coolbuy SaaS 平台管理端开发与部署。用于平台端前后端开发、租户管理、部署发布、翻译检查等任务。当用户提到 coolbuy-platform、平台端、租户管理后台相关任务时自动激活。 +--- + +# Coolbuy Platform Skill + +Coolbuy SaaS 平台管理端,用于管理所有租户、功能授权、计费、运营分析等。 + +## 项目信息 + +| 项目 | 值 | +|------|-----| +| 本地路径 | `/Users/donglinlai/coding/qiudl/coolbuy-platform` | +| Git 仓库 | `git@gitea.pipexerp.com:pipexerp/coolbuy-platform.git` | +| 主分支 | main | + +--- + +## 架构概览 + +``` +coolbuy-platform/ +├── service/ # Go 后端 (Gin + GORM) +│ ├── cmd/ # 入口 main.go +│ ├── internal/admin/ # 核心业务 +│ │ ├── api/ # HTTP handlers +│ │ ├── biz/ # 业务逻辑 +│ │ ├── store/ # 数据访问 +│ │ ├── model/ # 领域模型 +│ │ └── middleware/ # 中间件 +│ └── configs/ # 配置文件 +└── web/ # React 前端 (Vite + TypeScript) + ├── src/ + │ ├── api/ # API 客户端 + │ ├── components/ # 组件 + │ ├── pages/ # 页面 + │ ├── stores/ # Zustand 状态 + │ └── locales/ # i18n 翻译 + └── dist/ +``` + +--- + +## 部署环境 + +### 生产服务器 + +| 服务 | 地址 | 端口 | 容器名 | +|------|------|------|--------| +| 前端 | http://platform.pipexerp.com | 4999 | coolbuy-platform-web | +| 后端 | http://39.105.150.219 | 7090 | coolbuy-platform-service | +| Auth | http://39.105.150.219 | 7089 | coolbuy-auth-service | + +### 服务器信息 + +| 项目 | 值 | +|------|-----| +| IP | 39.105.150.219 | +| 用户 | root | +| SSH 密钥 | ~/.ssh/coolbuy3.pem | +| 操作系统 | Ubuntu 24.04 | + +### 数据库 + +| 项目 | 值 | +|------|-----| +| 类型 | PostgreSQL 16 | +| Host | 172.18.0.1 (Docker 网关) | +| Port | 5432 | +| Database | paas_platform | +| User | platform | +| Password | Coolbuy2025~ | + +### Docker Registry + +| 项目 | 值 | +|------|-----| +| Registry | Docker Hub | +| 账号 | saltthing123 | +| 前端镜像 | saltthing123/coolbuy-platform-web | +| 后端镜像 | saltthing123/coolbuy-platform-service | +| Auth镜像 | saltthing123/coolbuy-auth-service | + +--- + +## 快速部署命令 + +### 部署前端 + +```bash +cd /Users/donglinlai/coding/qiudl/coolbuy-platform/web + +# 1. 构建 +npx vite build + +# 2. 打包 Docker 镜像 +~/.orbstack/bin/docker build --platform linux/amd64 \ + -t saltthing123/coolbuy-platform-web: \ + -t saltthing123/coolbuy-platform-web:latest . + +# 3. 推送镜像 +~/.orbstack/bin/docker push saltthing123/coolbuy-platform-web: +~/.orbstack/bin/docker push saltthing123/coolbuy-platform-web:latest + +# 4. 部署到服务器 +ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 " +docker pull saltthing123/coolbuy-platform-web: && \ +docker stop coolbuy-platform-web && \ +docker rm coolbuy-platform-web && \ +docker run -d --name coolbuy-platform-web \ + --restart unless-stopped \ + -p 4999:80 \ + saltthing123/coolbuy-platform-web: +" +``` + +### 部署后端 + +```bash +cd /Users/donglinlai/coding/qiudl/coolbuy-platform/service + +# 1. 构建二进制 +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o coolbuy-platform-service cmd/main.go + +# 2. 打包 Docker 镜像 +~/.orbstack/bin/docker build --platform linux/amd64 \ + -t saltthing123/coolbuy-platform-service: \ + -t saltthing123/coolbuy-platform-service:latest . + +# 3. 推送镜像 +~/.orbstack/bin/docker push saltthing123/coolbuy-platform-service: +~/.orbstack/bin/docker push saltthing123/coolbuy-platform-service:latest + +# 4. 部署到服务器 +ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 " +docker pull saltthing123/coolbuy-platform-service: && \ +docker stop coolbuy-platform-service && \ +docker rm coolbuy-platform-service && \ +docker run -d --name coolbuy-platform-service \ + --restart unless-stopped \ + -p 7090:7090 \ + -v /data/coolbuy-platform/logs:/app/logs \ + -v /data/coolbuy-platform/storage:/app/storage \ + -v /data/coolbuy-platform/configs:/app/configs:ro \ + -e TZ=Asia/Shanghai \ + saltthing123/coolbuy-platform-service: \ + --config configs/config.prod.yaml +" +``` + +--- + +## 本地开发 + +### 启动后端 + +```bash +cd /Users/donglinlai/coding/qiudl/coolbuy-platform/service +go run cmd/main.go -config configs/config-dev.yaml +``` + +### 启动前端 + +```bash +cd /Users/donglinlai/coding/qiudl/coolbuy-platform/web +npm run dev +``` + +### 构建测试 + +```bash +# 后端测试 +cd service && go test -v ./... + +# 前端测试 +cd web && npm run test +``` + +--- + +## 翻译管理 + +### 翻译文件位置 + +- 简体中文: `web/src/locales/zh-CN.ts` +- 繁体中文: `web/src/locales/zh-TW.ts` + +### 检查翻译缺失 + +```bash +# 查找组件中使用的翻译 key +cd /Users/donglinlai/coding/qiudl/coolbuy-platform/web +grep -r "t(['\"]" src/components/ src/pages/ | grep -oE "t\(['\"][^'\"]+['\"]" | sort | uniq +``` + +### 翻译 key 命名规范 + +| 模块 | 前缀 | 示例 | +|------|------|------| +| 租户管理 | tenant.* | tenant.form.nameRequired | +| 用户管理 | user.* | user.createSuccess | +| 角色管理 | role.* | role.permissionAssigned | +| 菜单管理 | menu.* | menu.iconRequired | +| 通知中心 | notification.* | notification.markAllRead | + +--- + +## API 端点 + +### 认证 (Auth Service - 7089) + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /api/v1/auth/login | 登录 | +| POST | /api/v1/auth/logout | 登出 | +| POST | /api/v1/auth/refresh | 刷新 Token | + +### 平台管理 (Platform Service - 7090) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/admin/tenants | 租户列表 | +| POST | /api/v1/admin/tenants | 创建租户 | +| GET | /api/v1/admin/users | 用户列表 | +| GET | /api/v1/admin/roles | 角色列表 | +| GET | /api/v1/admin/menus | 菜单列表 | +| GET | /api/v1/admin/dashboard/overview | 仪表盘概览 | + +### 健康检查 + +| 路径 | 说明 | +|------|------| +| /health | 基础健康检查 | +| /health/detailed | 详细状态 | +| /readiness | K8s 就绪探针 | +| /liveness | K8s 存活探针 | + +--- + +## 数据库迁移 + +### 自动迁移 + +配置 `migration.auto: true` 时,服务启动自动运行迁移。 + +### 手动迁移 + +```bash +psql -h 172.18.0.1 -U platform -d paas_platform \ + -f service/configs/migrations/001_create_platform_admin_tables.sql +``` + +--- + +## 常见问题 + +### 1. 翻译 key 不显示 + +组件使用的 key 与 locale 定义不匹配。检查: +- 组件使用 `t('tenant.domainConfig.*')` 但 locale 定义为 `tenant.domain.*` +- 需要添加别名命名空间 + +### 2. SSE 连接错误 + +通过 Cloudflare Tunnel 访问时 SSE 长连接会中断,属于已知问题,会自动重连。 + +### 3. Docker 构建失败 + +确保使用 OrbStack 的 docker:`~/.orbstack/bin/docker` + +### 4. 数据库连接失败 + +Docker 网关 IP 是 `172.18.0.1`,不是默认的 `172.17.0.1`。 + +--- + +## 版本历史 + +| 版本 | 日期 | 变更 | +|------|------|------| +| 1.0.9 | 2026-01-05 | 修复租户管理全部翻译缺失 | +| 1.0.8 | 2026-01-04 | 修复租户列表翻译 | +| 1.0.7 | 2026-01-04 | 修复租户表单翻译 | +| 1.0.0 | 2026-01-03 | 初始版本 | + +--- + +## SSH 快捷命令 + +```bash +# 连接服务器 +ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 + +# 查看容器状态 +ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "docker ps | grep coolbuy" + +# 查看后端日志 +ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "docker logs -f coolbuy-platform-service --tail 100" + +# 查看前端日志 +ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "docker logs -f coolbuy-platform-web --tail 100" + +# 健康检查 +ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "curl -s http://localhost:7090/health" +``` + +--- + +## 创建租户 + +```bash +# 通过 API 创建租户 +curl -X POST http://39.105.150.219:7090/api/v1/admin/tenants \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "租户名称", + "code": "tenant_code", + "contact": "联系人", + "phone": "13800138000", + "email": "contact@example.com", + "user_limit": 50, + "storage_limit": 10, + "schema_type": "shared", + "admin_username": "tenantadmin", + "admin_real_name": "管理员姓名", + "status": "normal" + }' +``` + +--- + +## 相关技能 + +- `ops-tools` - DevOps 运维工具,包含 Jenkins/Gitea 管理 +- `dev-coding` - 软件编码开发 +- `coolbuy-paas` - 租户端系统(待创建) diff --git a/plugins/data-excel-plugin/.claude-plugin/plugin.json b/plugins/data-excel-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..5a42b7e --- /dev/null +++ b/plugins/data-excel-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "data-excel-plugin", + "description": "Plugin for data-excel", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/data-excel-plugin/skills/SKILL.md b/plugins/data-excel-plugin/skills/SKILL.md new file mode 100644 index 0000000..ec908e2 --- /dev/null +++ b/plugins/data-excel-plugin/skills/SKILL.md @@ -0,0 +1,443 @@ +--- +name: data-excel +description: Excel 数据处理与 BI 集成。通过自然语言操作 Excel 文件的读取、编辑、转换,并支持导入到 BI 系统(Metabase)进行可视化分析。当用户提到 Excel、表格处理、数据导入、BI 分析相关任务时自动激活。 +--- + +# Excel 数据处理与 BI 集成 Skill + +## 功能概述 + +- **Excel 读取**: 读取 .xlsx/.xls 文件内容,支持多 Sheet +- **Excel 编辑**: 修改单元格、添加/删除行列、格式化 +- **数据转换**: Excel ↔ CSV ↔ JSON ↔ SQL +- **BI 集成**: 导入数据到 Metabase 进行可视化 + +--- + +## 环境依赖 + +```bash +# Python 包(推荐使用 uv) +uv pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql + +# 或使用 pip +pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql +``` + +--- + +## 自然语言操作示例 + +### 读取操作 + +| 用户说 | 执行操作 | +|--------|----------| +| "读取这个 Excel 文件" | 读取并显示内容摘要 | +| "看一下第二个 Sheet" | 切换到指定 Sheet | +| "显示前 20 行" | 限制显示行数 | +| "这个表有哪些列" | 列出列名和数据类型 | + +### 编辑操作 + +| 用户说 | 执行操作 | +|--------|----------| +| "把 A 列的空值填充为 0" | 填充缺失值 | +| "删除重复行" | 去重 | +| "把日期列转成 YYYY-MM-DD 格式" | 格式化日期 | +| "添加一列计算总价=单价×数量" | 新增计算列 | +| "筛选销售额大于 1000 的记录" | 过滤数据 | +| "按部门汇总销售额" | 分组聚合 | + +### 导出操作 + +| 用户说 | 执行操作 | +|--------|----------| +| "导出为 CSV" | 转换格式 | +| "生成 SQL 插入语句" | 生成 INSERT 语句 | +| "导入到数据库" | 写入 MySQL/PostgreSQL | +| "上传到 Metabase" | BI 系统集成 | + +--- + +## Python 代码模板 + +### 读取 Excel + +```python +import pandas as pd + +# 读取 Excel 文件 +df = pd.read_excel('data.xlsx') + +# 读取指定 Sheet +df = pd.read_excel('data.xlsx', sheet_name='Sheet2') + +# 读取所有 Sheet +all_sheets = pd.read_excel('data.xlsx', sheet_name=None) +for name, sheet_df in all_sheets.items(): + print(f"Sheet: {name}, 行数: {len(sheet_df)}") + +# 显示基本信息 +print(df.head(10)) # 前 10 行 +print(df.columns) # 列名 +print(df.dtypes) # 数据类型 +print(df.describe()) # 统计摘要 +``` + +### 编辑 Excel + +```python +import pandas as pd + +df = pd.read_excel('data.xlsx') + +# 填充空值 +df['列名'].fillna(0, inplace=True) + +# 删除重复行 +df.drop_duplicates(inplace=True) + +# 添加计算列 +df['总价'] = df['单价'] * df['数量'] + +# 筛选数据 +df_filtered = df[df['销售额'] > 1000] + +# 分组汇总 +df_summary = df.groupby('部门')['销售额'].sum().reset_index() + +# 日期格式化 +df['日期'] = pd.to_datetime(df['日期']).dt.strftime('%Y-%m-%d') + +# 重命名列 +df.rename(columns={'旧列名': '新列名'}, inplace=True) + +# 排序 +df.sort_values(by='销售额', ascending=False, inplace=True) + +# 保存修改 +df.to_excel('output.xlsx', index=False) +``` + +### 格式化 Excel(带样式) + +```python +import pandas as pd +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + +# 创建 Excel 并设置样式 +df = pd.read_excel('data.xlsx') + +with pd.ExcelWriter('styled_output.xlsx', engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='数据') + + workbook = writer.book + worksheet = writer.sheets['数据'] + + # 设置标题行样式 + header_font = Font(bold=True, color='FFFFFF') + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + + for col in range(1, len(df.columns) + 1): + cell = worksheet.cell(row=1, column=col) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center') + + # 自动调整列宽 + for column in worksheet.columns: + max_length = max(len(str(cell.value or '')) for cell in column) + worksheet.column_dimensions[column[0].column_letter].width = max_length + 2 +``` + +--- + +## 数据转换 + +### Excel → CSV + +```python +import pandas as pd + +df = pd.read_excel('data.xlsx') +df.to_csv('output.csv', index=False, encoding='utf-8-sig') # utf-8-sig 解决中文乱码 +``` + +### Excel → JSON + +```python +import pandas as pd + +df = pd.read_excel('data.xlsx') + +# 转为 JSON 数组 +json_str = df.to_json(orient='records', force_ascii=False, indent=2) +print(json_str) + +# 保存为文件 +with open('output.json', 'w', encoding='utf-8') as f: + f.write(json_str) +``` + +### Excel → SQL INSERT + +```python +import pandas as pd + +df = pd.read_excel('data.xlsx') +table_name = 'my_table' + +# 生成 INSERT 语句 +def generate_insert_sql(df, table_name): + columns = ', '.join(df.columns) + values_list = [] + for _, row in df.iterrows(): + values = ', '.join([f"'{v}'" if isinstance(v, str) else str(v) for v in row]) + values_list.append(f"({values})") + + sql = f"INSERT INTO {table_name} ({columns}) VALUES\n" + ',\n'.join(values_list) + ';' + return sql + +sql = generate_insert_sql(df, table_name) +print(sql) +``` + +### Excel → MySQL 直接导入 + +```python +import pandas as pd +from sqlalchemy import create_engine + +# 数据库连接 +engine = create_engine('mysql+pymysql://user:password@host:3306/database') + +# 读取 Excel +df = pd.read_excel('data.xlsx') + +# 导入数据库(如果表存在则替换) +df.to_sql('table_name', engine, if_exists='replace', index=False) + +# 或追加数据 +df.to_sql('table_name', engine, if_exists='append', index=False) +``` + +--- + +## BI 系统集成 (Metabase) + +### Metabase 服务信息 + +| 项目 | 值 | +|------|-----| +| 服务器 | prod-metaBI (192.144.174.87) | +| SSH 用户 | ubuntu | +| SSH 密钥 | ~/.ssh/prod_meta.pem | +| 系统 | Ubuntu 24.04 LTS | +| 数据源 | MySQL | +| 所属公司 | 北京欢乐宿 | + +### SSH 连接 + +```bash +# 连接 Metabase 服务器 +ssh prod-metaBI + +# 查看 MySQL 状态 +ssh prod-metaBI "docker ps | grep mysql" + +# 查看 Metabase 容器状态 +ssh prod-metaBI "docker ps | grep metabase" +``` + +### MySQL 连接配置 (Metabase 数据源) + +| 配置项 | 值 | +|--------|-----| +| Host | `127.0.0.1` | +| Port | `3306` | +| Database | `finance_db` | +| Username | `root` | +| Password | `root123456` | + +> **注意**: MySQL 8.0 需使用 `mysql_native_password` 认证,否则会报 RSA 公钥错误。 + +### 数据导入流程 + +``` +Excel 文件 → Python 处理 → MySQL 数据库 → Metabase 可视化 +``` + +### 完整导入脚本 + +```python +import pandas as pd +from sqlalchemy import create_engine +import os + +def excel_to_metabase(excel_path, table_name, db_config): + """ + 将 Excel 数据导入到 Metabase 可查询的数据库 + + Args: + excel_path: Excel 文件路径 + table_name: 目标表名 + db_config: 数据库配置 dict + """ + # 读取 Excel + df = pd.read_excel(excel_path) + + # 数据清洗 + df.columns = df.columns.str.strip() # 去除列名空格 + df = df.dropna(how='all') # 删除全空行 + + # 连接数据库 + engine = create_engine( + f"mysql+pymysql://{db_config['user']}:{db_config['password']}" + f"@{db_config['host']}:{db_config['port']}/{db_config['database']}" + ) + + # 导入数据 + df.to_sql(table_name, engine, if_exists='replace', index=False) + + print(f"✓ 已导入 {len(df)} 行数据到表 {table_name}") + print(f"→ 现在可以在 Metabase 中查询此表") + + return df + +# 使用示例 +db_config = { + 'host': 'localhost', + 'port': 3306, + 'user': 'metabase_user', + 'password': 'your_password', + 'database': 'analytics' +} + +excel_to_metabase('sales_data.xlsx', 'sales_report', db_config) +``` + +### Metabase 常用查询模板 + +导入数据后,在 Metabase 中可使用以下查询: + +```sql +-- 数据概览 +SELECT * FROM imported_table LIMIT 100; + +-- 按日期汇总 +SELECT DATE(created_at) as date, COUNT(*) as count, SUM(amount) as total +FROM imported_table +GROUP BY DATE(created_at) +ORDER BY date; + +-- 分类统计 +SELECT category, COUNT(*) as count, AVG(value) as avg_value +FROM imported_table +GROUP BY category +ORDER BY count DESC; +``` + +--- + +## 常见场景 + +### 场景 1: 合并多个 Excel 文件 + +```python +import pandas as pd +import glob + +# 合并目录下所有 Excel 文件 +files = glob.glob('data/*.xlsx') +df_list = [pd.read_excel(f) for f in files] +df_merged = pd.concat(df_list, ignore_index=True) + +df_merged.to_excel('merged.xlsx', index=False) +print(f"已合并 {len(files)} 个文件,共 {len(df_merged)} 行") +``` + +### 场景 2: 数据透视表 + +```python +import pandas as pd + +df = pd.read_excel('sales.xlsx') + +# 创建透视表 +pivot = pd.pivot_table( + df, + values='销售额', + index='产品类别', + columns='月份', + aggfunc='sum', + fill_value=0 +) + +pivot.to_excel('pivot_report.xlsx') +``` + +### 场景 3: 数据校验 + +```python +import pandas as pd + +df = pd.read_excel('data.xlsx') + +# 检查空值 +null_counts = df.isnull().sum() +print("空值统计:\n", null_counts[null_counts > 0]) + +# 检查重复 +duplicates = df[df.duplicated()] +print(f"重复行数: {len(duplicates)}") + +# 数据类型检查 +print("数据类型:\n", df.dtypes) +``` + +### 场景 4: 生成报表 + +```python +import pandas as pd + +df = pd.read_excel('data.xlsx') + +# 生成多 Sheet 报表 +with pd.ExcelWriter('report.xlsx') as writer: + # 原始数据 + df.to_excel(writer, sheet_name='原始数据', index=False) + + # 汇总数据 + summary = df.groupby('部门').agg({ + '销售额': 'sum', + '订单数': 'count' + }).reset_index() + summary.to_excel(writer, sheet_name='部门汇总', index=False) + + # 统计信息 + stats = df.describe() + stats.to_excel(writer, sheet_name='统计信息') +``` + +--- + +## 工作流程 + +``` +1. 用户上传 Excel 文件或提供路径 +2. 用自然语言描述需求(如"按月份汇总销售额") +3. Claude 生成并执行 Python 代码 +4. 返回处理结果或导出文件 +5. 可选:导入到 BI 系统进行可视化 +``` + +--- + +## 注意事项 + +- Excel 文件路径使用绝对路径更可靠 +- 中文 CSV 导出使用 `encoding='utf-8-sig'` 避免乱码 +- 大文件(>10万行)考虑分批处理 +- 导入数据库前先备份现有数据 +- 敏感数据注意脱敏处理 diff --git a/plugins/dev-arch-plugin/.claude-plugin/plugin.json b/plugins/dev-arch-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..e0e65b4 --- /dev/null +++ b/plugins/dev-arch-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "dev-arch-plugin", + "description": "Plugin for dev-arch", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/dev-arch-plugin/skills/SKILL.md b/plugins/dev-arch-plugin/skills/SKILL.md new file mode 100644 index 0000000..05b91f4 --- /dev/null +++ b/plugins/dev-arch-plugin/skills/SKILL.md @@ -0,0 +1,390 @@ +--- +name: dev-arch +description: 软件架构设计技能。用于系统设计、技术选型、架构评审、设计文档编写。当用户提到架构设计、系统设计、技术方案、API 设计相关任务时自动激活。 +--- + +# 软件架构设计 Skill (dev-arch) + +## 概述 + +本技能用于软件架构设计工作,包括: +- 系统架构设计与评审 +- 技术选型与方案对比 +- API 设计与文档 +- 数据库设计 +- 设计模式应用 + +--- + +## 架构设计流程 + +### 1. 需求分析 + +``` +输入: +- 业务需求文档 +- 非功能性需求(性能、安全、可用性) +- 技术约束条件 + +输出: +- 需求清单 +- 约束条件列表 +``` + +### 2. 概要设计 + +``` +内容: +- 系统边界定义 +- 模块划分 +- 技术栈选择 +- 部署架构 + +输出: +- 架构图 +- 技术方案文档 +``` + +### 3. 详细设计 + +``` +内容: +- API 接口设计 +- 数据模型设计 +- 核心流程设计 +- 异常处理设计 + +输出: +- API 文档 +- ER 图 +- 时序图 +``` + +--- + +## 架构设计模板 + +### 系统设计文档模板 + +```markdown +# [系统名称] 技术设计文档 + +## 1. 概述 +### 1.1 背景 +[项目背景和目标] + +### 1.2 范围 +[系统边界和功能范围] + +### 1.3 术语定义 +| 术语 | 定义 | +|------|------| +| ... | ... | + +## 2. 系统架构 +### 2.1 整体架构图 +[架构图] + +### 2.2 模块说明 +| 模块 | 职责 | 技术栈 | +|------|------|--------| +| ... | ... | ... | + +### 2.3 部署架构 +[部署图] + +## 3. 技术选型 +### 3.1 技术栈 +| 类别 | 选择 | 理由 | +|------|------|------| +| 后端框架 | ... | ... | +| 数据库 | ... | ... | +| 缓存 | ... | ... | +| 消息队列 | ... | ... | + +### 3.2 技术对比 +[备选方案对比分析] + +## 4. 数据设计 +### 4.1 ER 图 +[ER 图] + +### 4.2 核心表设计 +[表结构说明] + +### 4.3 索引策略 +[索引设计] + +## 5. API 设计 +### 5.1 API 规范 +[RESTful 规范说明] + +### 5.2 核心接口 +[接口定义] + +## 6. 非功能性设计 +### 6.1 性能设计 +- 响应时间目标 +- 吞吐量目标 +- 优化策略 + +### 6.2 安全设计 +- 认证授权 +- 数据安全 +- 日志审计 + +### 6.3 可用性设计 +- 容错机制 +- 监控告警 +- 备份恢复 + +## 7. 风险评估 +| 风险 | 影响 | 概率 | 应对措施 | +|------|------|------|----------| +| ... | ... | ... | ... | + +## 8. 附录 +- 参考资料 +- 变更历史 +``` + +--- + +## 常用架构模式 + +### 分层架构 + +``` +┌─────────────────────────────────┐ +│ 表现层 (Presentation) │ +├─────────────────────────────────┤ +│ 业务层 (Business) │ +├─────────────────────────────────┤ +│ 数据层 (Data Access) │ +├─────────────────────────────────┤ +│ 基础设施 (Infrastructure) │ +└─────────────────────────────────┘ +``` + +### 微服务架构 + +``` +┌─────┐ ┌─────┐ ┌─────┐ +│ API │ │ API │ │ API │ +│ GW │──│ SVC │──│ SVC │ +└──┬──┘ └──┬──┘ └──┬──┘ + │ │ │ + └────────┴────────┘ + │ + ┌────┴────┐ + │ Message │ + │ Queue │ + └─────────┘ +``` + +### 六边形架构 (端口-适配器) + +``` + ┌─────────────────┐ + ┌──────│ Adapters │──────┐ + │ │ (Inbound) │ │ + │ └────────┬────────┘ │ + │ │ │ +┌───┴───┐ ┌──────┴──────┐ ┌────┴────┐ +│ REST │ │ Core │ │ Repo │ +│ API │────│ Business │───│ Impl │ +└───────┘ │ Domain │ └─────────┘ + └─────────────┘ +``` + +--- + +## API 设计规范 + +### RESTful 规范 + +| 方法 | 用途 | 示例 | +|------|------|------| +| GET | 查询资源 | `GET /users/{id}` | +| POST | 创建资源 | `POST /users` | +| PUT | 全量更新 | `PUT /users/{id}` | +| PATCH | 部分更新 | `PATCH /users/{id}` | +| DELETE | 删除资源 | `DELETE /users/{id}` | + +### 响应格式 + +```json +{ + "code": 0, + "message": "success", + "data": {}, + "meta": { + "page": 1, + "limit": 20, + "total": 100 + } +} +``` + +### 错误码设计 + +| 范围 | 类型 | 示例 | +|------|------|------| +| 10000-19999 | 系统错误 | 10001 内部错误 | +| 20000-29999 | 参数错误 | 20001 参数缺失 | +| 30000-39999 | 业务错误 | 30001 用户不存在 | +| 40000-49999 | 权限错误 | 40001 未授权 | + +--- + +## 数据库设计 + +### 命名规范 + +| 类型 | 规范 | 示例 | +|------|------|------| +| 表名 | 小写下划线,复数 | `user_accounts` | +| 字段 | 小写下划线 | `created_at` | +| 主键 | id | `id` | +| 外键 | 表名_id | `user_id` | +| 索引 | idx_表名_字段 | `idx_users_email` | + +### 通用字段 + +```sql +-- 每个表必须包含的字段 +id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, +created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, +deleted_at DATETIME DEFAULT NULL -- 软删除 +``` + +### 索引策略 + +- 主键索引:每表必须 +- 唯一索引:业务唯一字段 +- 普通索引:高频查询字段 +- 联合索引:遵循最左前缀原则 + +--- + +## 技术选型参考 + +### 后端框架 + +| 语言 | 框架 | 适用场景 | +|------|------|----------| +| Go | Gin, Echo | 高并发 API | +| Java | Spring Boot | 企业应用 | +| Python | FastAPI, Django | 快速开发 | +| Node.js | NestJS, Express | 全栈/实时 | + +### 数据库选型 + +| 类型 | 选项 | 适用场景 | +|------|------|----------| +| 关系型 | MySQL, PostgreSQL | 事务型业务 | +| 文档型 | MongoDB | 灵活结构 | +| 键值型 | Redis | 缓存/会话 | +| 时序 | InfluxDB | 监控/IoT | +| 搜索 | Elasticsearch | 全文检索 | + +### 消息队列 + +| 选项 | 特点 | 适用场景 | +|------|------|----------| +| Redis | 简单快速 | 轻量级队列 | +| RabbitMQ | 可靠传递 | 企业集成 | +| Kafka | 高吞吐 | 日志/流处理 | +| NATS | 轻量级 | 微服务通信 | + +--- + +## 架构评审检查清单 + +### 功能性检查 +- [ ] 需求覆盖完整 +- [ ] 边界条件处理 +- [ ] 异常情况处理 +- [ ] 数据一致性保证 + +### 非功能性检查 +- [ ] 性能目标明确 +- [ ] 安全措施到位 +- [ ] 可扩展性设计 +- [ ] 可维护性考虑 + +### 运维检查 +- [ ] 监控指标定义 +- [ ] 日志规范 +- [ ] 部署方案 +- [ ] 回滚机制 + +--- + +## 与 ai-proj 集成 + +### 创建架构设计任务 + +```bash +# 创建架构设计任务 +ai-proj task create --title "[系统名称] 架构设计" + +# 创建子任务 +ai-proj task create --title "需求分析" --parent-id +ai-proj task create --title "概要设计" --parent-id +ai-proj task create --title "详细设计" --parent-id +ai-proj task create --title "架构评审" --parent-id +``` + +### 关联设计文档 + +```bash +# 附加设计文档到任务 +ai-proj task append-doc --id --content "设计文档内容 (Markdown)" +``` + +--- + +## 最佳实践 + +1. **KISS 原则** - 保持简单,避免过度设计 +2. **YAGNI 原则** - 不要预测未来需求 +3. **DRY 原则** - 避免重复代码 +4. **SOLID 原则** - 面向对象设计原则 +5. **关注点分离** - 模块职责单一 +6. **高内聚低耦合** - 模块独立性 + +--- + +## 文档工具 + +### 架构图工具 + +- **draw.io** - 在线免费绘图 +- **PlantUML** - 代码生成图 +- **Mermaid** - Markdown 内嵌图 +- **Excalidraw** - 手绘风格 + +### Mermaid 示例 + +```mermaid +sequenceDiagram + Client->>API Gateway: Request + API Gateway->>Auth Service: Validate Token + Auth Service-->>API Gateway: Valid + API Gateway->>Business Service: Forward Request + Business Service->>Database: Query + Database-->>Business Service: Result + Business Service-->>API Gateway: Response + API Gateway-->>Client: Response +``` + +--- + +## 参考资源 + +- [系统设计入门](https://github.com/donnemartin/system-design-primer) +- [微服务架构](https://microservices.io/) +- [RESTful API 设计指南](https://restfulapi.net/) +- [数据库设计范式](https://www.guru99.com/database-normalization.html) diff --git a/plugins/dev-coding-plugin/.claude-plugin/plugin.json b/plugins/dev-coding-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..e5688b2 --- /dev/null +++ b/plugins/dev-coding-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "dev-coding-plugin", + "description": "Plugin for dev-coding", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/dev-coding-plugin/skills/SKILL.md b/plugins/dev-coding-plugin/skills/SKILL.md new file mode 100644 index 0000000..7c9ba06 --- /dev/null +++ b/plugins/dev-coding-plugin/skills/SKILL.md @@ -0,0 +1,810 @@ +--- +name: dev-coding +description: 软件编码开发技能。用于代码编写、功能实现、代码审查、重构优化。集成 ai-proj CLI 进行任务管理和进度跟踪。支持 Go、Vue、React、iOS、Android、小程序等全栈开发。 +--- + +# 软件编码开发 Skill (dev-coding) + +## ⚠️ REQ 任务自动工作流 + +**当收到 REQ 任务(包含 REQ-YYYYMMDD-XXXX)需要开发时,必须严格按以下顺序执行:** + +1. **读取 ticket** — 从 ai-proj 获取需求详情和关联文档 + ``` + mcp__ai-proj-dev__get_detailed_task_info (通过 REQ 号查找) + mcp__ai-proj-dev__get_task_document (如果有 PRD 文档) + ``` + +2. **进入 Plan Mode** — 调用 `EnterPlanMode` 工具 + - 分析需求,探索代码库,设计实现方案 + - 输出实现计划(涉及的文件、改动范围、测试策略) + - 等待用户审批后再开始编码 + +3. **执行计划** — 用户批准后按计划编码 + 写测试 + +**禁止跳过 plan mode 直接编码。** + +--- + +## 概述 + +本技能用于软件编码开发工作,支持多种项目类型: +- Go 后端 (Gin + GORM) +- Vue 3 / React 前端 +- iOS (Swift/SwiftUI) +- Android (Kotlin/Jetpack Compose) +- PDA 应用 +- MCP 桥接服务 +- 微服务架构 + +核心集成 **ai-proj CLI** 进行任务管理。 + +--- + +## ai-proj 任务管理 + +### 开发任务工作流 + +``` +1. 查看/创建任务 → 2. 启动任务 → 3. 编码实现 → 4. 完成任务 → 5. 记录文档 +``` + +### 任务操作速查 + +| 操作 | CLI 命令 | 说明 | +|------|----------|------| +| 查看任务列表 | `ai-proj task list` | 查看项目所有任务 | +| 创建任务 | `ai-proj task create` | 创建新任务 | +| 创建子任务 | `ai-proj task create --parent-id` | 分解任务 | +| 启动任务 | `ai-proj task start --id` | 开始执行 | +| 完成任务 | `ai-proj task complete --id` | 标记完成 | +| 更新任务 | `ai-proj task update --id` | 更新状态/描述 | +| 查看详情 | `ai-proj task get --id` | 完整任务信息 | +| 记录文档 | `ai-proj task append-doc --id` | 附加文档 | + +### 开始新任务 + +```bash +# 1. 查看任务列表 +ai-proj task list --status todo,in_progress + +# 2. 启动任务 +ai-proj task start --id + +# 3. 完成后 +ai-proj task complete --id + +# 4. 记录文档 +ai-proj task append-doc --id --content "实现说明" +``` + +--- + +## 项目类型速查 + +### 当前项目生态 + +| 项目 | 类型 | 后端 | 前端 | 移动端 | +|------|------|------|------|--------| +| TWMS | 仓储物流 | Go+Gin+MySQL | Vue 3 | - | +| AI-Proj | 项目管理 | Go+Gin+PostgreSQL | React 18 | iOS+Android | +| DICIAI | 进销存SaaS | Go+Gin+MySQL | Vue 3 | Android PDA | + +--- + +## Go 后端开发 + +### 分层架构 + +``` +backend/ +├── cmd/main.go # 入口点 +├── internal/ +│ ├── controller/handlers/ # HTTP 处理层 +│ ├── biz/services/ # 业务逻辑层 +│ ├── store/database/ # 数据访问层 +│ └── middleware/ # 中间件 +├── pkg/ +│ ├── model/ # 数据模型 +│ ├── errno/ # 错误定义 +│ ├── api/ # API 类型 +│ └── util/ # 工具函数 +└── configs/migrations/ # 配置和迁移 +``` + +### 代码规范 + +```go +// 包声明和导入组织 +package main + +import ( + // 标准库 + "context" + "fmt" + + // 第三方 + "github.com/gin-gonic/gin" + + // 项目内部 + "project/internal/pkg/errno" +) + +// 错误处理 (Errno 模式) +if err != nil { + core.WriteResponse(c, errno.ErrBind, nil) + return +} + +// 接口定义 +type IStore interface { + Users() UserStore +} + +// 服务注入 +type userBiz struct { + ds store.IStore +} + +func NewUserBiz(ds store.IStore) *userBiz { + return &userBiz{ds: ds} +} +``` + +### 常用命令 + +```bash +# 构建 +make build +go build -o ./_output/main ./cmd/main.go + +# 运行 +./_output/main --config ./configs/config.yaml + +# 测试 +make test +go test -v ./... + +# 代码检查 +make lint +golangci-lint run + +# Swagger 文档 +make swagger +swag init -g cmd/main.go +``` + +### 数据库模型 + +```go +type UserM struct { + Id int64 `gorm:"column:id;primary_key"` + Username string `gorm:"column:username;not null"` + CreateTime int64 `gorm:"column:create_time"` + UpdateTime int64 `gorm:"column:update_time"` + DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"` +} + +func (m *UserM) TableName() string { return "users" } + +func (m *UserM) BeforeCreate(tx *gorm.DB) error { + m.CreateTime = time.Now().Unix() + m.UpdateTime = m.CreateTime + return nil +} +``` + +--- + +## 前端 data-testid 规范 + +编写或修改前端组件时,**所有可交互元素必须加 `data-testid`**。 + +**命名格式:** `<模块>-<元素类型>[-<标识>]` + +```vue + + +创建商品 + + + + + +创建商品 +``` + +**必须加:** 输入框、选择器、开关、按钮(提交/取消/删除)、表格、模态框确认按钮、导航菜单项 +**不需要加:** 纯展示文本、图标、布局容器(Row/Col/Space) + +--- + +## Vue 3 前端开发 + +### 项目结构 + +``` +frontend/src/ +├── api/ # API 服务 (按模块分组) +│ ├── wms/ # 仓储管理 +│ ├── oms/ # 订单管理 +│ └── system/ # 系统管理 +├── views/ # 页面组件 +├── components/ # 可复用组件 +├── store/modules/ # Pinia 状态 +├── router/ # 路由配置 +├── utils/ +│ ├── request.ts # Axios 拦截器 +│ └── permission.ts # 权限检查 +└── i18n/ # 国际化 +``` + +### 代码规范 + +```typescript +// API 服务层 +// api/user/model/index.ts +export interface User { + id: number; + username: string; +} + +// api/user/index.ts +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api'; +import type { User } from './model'; + +export async function getUsers(params: UserParams) { + const res = await request.get>>( + '/v1/users', + { params } + ); + if (res.status === 200) { + return res.data; + } + return Promise.reject(new Error(res.data.message)); +} +``` + +### 常用命令 + +```bash +# 安装依赖 +npm install +pnpm install # DICIAI 使用 pnpm + +# 开发 +npm run dev + +# 构建 +npm run build:prod +npm run build:test + +# 代码检查 +npm run lint:eslint +``` + +--- + +## React 前端开发 + +### 项目结构 + +``` +frontend/src/ +├── pages/ # 页面组件 +├── components/ # 可复用组件 +├── services/ # API 服务 +├── hooks/ # 自定义 Hooks +├── contexts/ # Context Providers +├── types/ # TypeScript 类型 +├── utils/ # 工具函数 +└── config/ # 配置 +``` + +### 代码规范 + +```typescript +// Context 集成 + + + + + + + + + + + + +// API 服务 +const api = axios.create({ + baseURL: API_BASE_URL, + timeout: 120000, +}); + +api.interceptors.request.use((config) => { + const token = TokenManager.getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); +``` + +### 常用命令 + +```bash +# 开发 +npm start + +# 构建 +npm run build + +# 测试 +npm test +npm run test:e2e +``` + +--- + +## iOS 开发 (Swift/SwiftUI) + +### 项目结构 + +``` +AI-Proj-iOS/ +├── Core/ # 核心服务 +│ ├── Network/ # 网络层 +│ ├── Storage/ # 本地存储 +│ └── Auth/ # 认证 +├── Features/ # 功能模块 +│ ├── Dashboard/ +│ ├── Tasks/ +│ └── Settings/ +├── Models/ # 数据模型 +└── UI/ # UI 组件 +``` + +### 代码规范 + +```swift +// MVVM 架构 +class TaskViewModel: ObservableObject { + @Published var tasks: [Task] = [] + @Published var isLoading = false + + private let taskService: TaskServiceProtocol + + init(taskService: TaskServiceProtocol = TaskService()) { + self.taskService = taskService + } + + func fetchTasks() async { + isLoading = true + defer { isLoading = false } + + do { + tasks = try await taskService.getTasks() + } catch { + // 错误处理 + } + } +} + +// SwiftUI 视图 +struct TaskListView: View { + @StateObject private var viewModel = TaskViewModel() + + var body: some View { + List(viewModel.tasks) { task in + TaskRow(task: task) + } + .task { + await viewModel.fetchTasks() + } + } +} +``` + +### 构建命令 + +```bash +# Xcode 构建 +xcodebuild -scheme AI-Proj-iOS -configuration Debug + +# 测试 +xcodebuild test -scheme AI-Proj-iOS +``` + +### 常见问题排查 + +#### SwiftLint 沙盒错误 + +**问题描述**: +构建时出现错误: +``` +Sandbox: swiftlint(xxxx) deny(1) file-read-data /path/to/.swiftlint.yml +``` + +**原因**: +Xcode 15+ 默认启用 User Script Sandboxing,限制脚本访问文件系统。 + +**解决方案**: + +方案 1 - 修改项目配置(推荐): +1. 打开 Xcode → 选择项目 → Build Settings +2. 搜索 "User Script Sandboxing" +3. 将 `ENABLE_USER_SCRIPT_SANDBOXING` 设置为 `NO` + +方案 2 - 命令行构建时禁用: +```bash +xcodebuild -scheme AI-Proj-iOS -configuration Debug \ + ENABLE_USER_SCRIPT_SANDBOXING=NO +``` + +方案 3 - 直接修改 project.pbxproj: +```bash +sed -i '' 's/ENABLE_USER_SCRIPT_SANDBOXING = YES/ENABLE_USER_SCRIPT_SANDBOXING = NO/g' \ + AI-Proj-iOS.xcodeproj/project.pbxproj +``` + +#### Personal Development Team 功能限制 + +**问题描述**: +使用免费 Personal Team 签名时报错: +``` +Cannot create iOS App Development provisioning profile... +Personal development teams do not support the Associated Domains, +Push Notifications and App Groups capabilities. +``` + +**原因**: +Personal Team(免费账户)不支持以下 Entitlements: +- Associated Domains (`com.apple.developer.associated-domains`) +- Push Notifications (`aps-environment`) +- App Groups (`com.apple.security.application-groups`) + +**解决方案**: + +1. 从 Entitlements 文件中移除不支持的功能: + +```xml + + + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.yourcompany.app + + + +``` + +2. Personal Team 支持的功能: + - Keychain Access Groups ✓ + - In-App Purchase ✓ + - Game Center ✓ + +3. 需要付费 Apple Developer Program 的功能: + - Push Notifications ✗ + - Associated Domains ✗ + - App Groups ✗ + - CloudKit ✗ + - Sign in with Apple ✗ + +--- + +## Android 开发 (Kotlin) + +### 项目结构 + +``` +android-app/app/src/main/ +├── java/com/project/ +│ ├── ui/ # UI 层 +│ │ ├── screens/ # Compose 屏幕 +│ │ └── components/ # 可复用组件 +│ ├── data/ # 数据层 +│ │ ├── api/ # 网络接口 +│ │ ├── repository/ # 仓库模式 +│ │ └── local/ # 本地存储 +│ ├── domain/ # 业务逻辑 +│ └── di/ # 依赖注入 +└── res/ # 资源文件 +``` + +### 代码规范 + +```kotlin +// Hilt 依赖注入 +@HiltViewModel +class TaskViewModel @Inject constructor( + private val taskRepository: TaskRepository +) : ViewModel() { + + private val _tasks = MutableStateFlow>(emptyList()) + val tasks: StateFlow> = _tasks.asStateFlow() + + fun fetchTasks() { + viewModelScope.launch { + taskRepository.getTasks() + .collect { _tasks.value = it } + } + } +} + +// Jetpack Compose +@Composable +fun TaskListScreen( + viewModel: TaskViewModel = hiltViewModel() +) { + val tasks by viewModel.tasks.collectAsState() + + LazyColumn { + items(tasks) { task -> + TaskItem(task = task) + } + } +} +``` + +### 构建命令 + +```bash +# 构建 Debug +./gradlew assembleDebug + +# 构建 Release +./gradlew assembleRelease + +# 测试 +./gradlew test +``` + +--- + +## PDA 应用开发 + +### 特点 + +- Android 原生开发 +- 扫码枪集成 +- 离线优先 +- 简洁 UI + +### 常见功能 + +```kotlin +// 扫码处理 +class ScanReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val barcode = intent.getStringExtra("SCAN_BARCODE") + // 处理扫码结果 + } +} + +// 离线存储 +@Entity(tableName = "inventory") +data class Inventory( + @PrimaryKey val id: Long, + val barcode: String, + val quantity: Int, + @ColumnInfo(name = "sync_status") + val syncStatus: SyncStatus = SyncStatus.PENDING +) +``` + +--- + +## MCP 桥接开发 + +### 项目结构 + +``` +mcp-task-bridge/ +├── index.ts # 入口 +├── task-service.ts # 任务服务 +├── document-service.ts # 文档服务 +├── base-client.ts # HTTP 基类 +├── types.ts # 类型定义 +└── token-storage.ts # Token 管理 +``` + +### 代码规范 + +```typescript +// 服务类模式 +export class TaskService extends BaseClient { + async createTask( + title: string, + projectId: number = 1, + options: CreateTaskOptions = {} + ): Promise> { + try { + const response = await this.makeRequest( + 'POST', + `/projects/${projectId}/tasks`, + { title, project_id: projectId, ...options } + ); + + if (response.success) { + return { + success: true, + data: response.data, + message: `✅ 任务 "${title}" 创建成功` + }; + } + return response; + } catch (error: any) { + return { + success: false, + error: `创建任务失败: ${error.message}` + }; + } + } +} +``` + +--- + +## 通用开发规范 + +### API 响应格式 + +```json +{ + "code": 0, + "message": "success", + "data": {} +} +``` + +### 分页参数 + +```json +{ + "page": 1, + "limit": 20, + "sort": "created_at", + "order": "desc" +} +``` + +### 认证方式 + +- JWT Token +- Header: `Authorization: Bearer ` + +### 错误处理 + +```go +// Go +if err != nil { + core.WriteResponse(c, errno.ErrXxx, nil) + return +} + +// TypeScript +try { + const result = await api.call(); +} catch (error) { + message.error(error.message); +} + +// Swift +do { + let result = try await service.fetch() +} catch { + // 处理错误 +} +``` + +--- + +## Git 工作流 + +### 提交规范 + +| 类型 | 说明 | +|------|------| +| feat | 新功能 | +| fix | Bug 修复 | +| docs | 文档 | +| refactor | 重构 | +| test | 测试 | +| chore | 杂项 | + +### 双电脑同步 (au-dev / cn-dev) + +```bash +# 离开时 +git add -A +git commit -m "WIP: sync from $(hostname)" +git push origin $(git branch --show-current) + +# 到达时 +git fetch origin +git pull origin $(git branch --show-current) +``` + +--- + +## Docker 部署 + +### 标准配置 + +```yaml +# docker-compose.yml +services: + backend: + build: ./backend + ports: + - "8080:8080" + depends_on: + - db + - redis + + frontend: + build: ./frontend + ports: + - "80:80" + + db: + image: mysql:8.0 + # 或 postgres:15 + + redis: + image: redis:alpine +``` + +### 常用端口 + +| 服务 | 端口 | +|------|------| +| Backend | 8080 / 9099 | +| Frontend | 80 / 3000 | +| MySQL | 3306 | +| PostgreSQL | 5432 | +| Redis | 6379 | + +--- + +## Push 前必须通过:变更包单元测试 + +**在 `git push` 或 `/pr create` 之前,必须跑所有变更文件对应包的单元测试。** + +```bash +# 找出变更的 Go 文件所在包,跑对应测试 +PKGS=$(git diff --name-only origin/main...HEAD | grep '\.go$' | grep -v '_test\.go' | sed 's|/[^/]*$||' | sort -u | sed 's|^|./|' | tr '\n' ' ') + +if [ -n "$PKGS" ]; then + echo "Running tests for changed packages: $PKGS" + go test -v -count=1 $PKGS +else + echo "No Go files changed, skipping tests" +fi +``` + +**规则:** +- 测试通过 → 继续 push + `/pr create` +- 测试失败 → 尝试自动修复,修复后重跑 + - 修复成功 → 继续 push + - **修复失败 → 禁止 push,向用户报告失败原因,等待指示** +- 仅改了 `_test.go` → 同样需要跑(验证测试本身通过) +- 无 Go 文件变更(纯前端/文档) → 跳过 + +--- + +## 最佳实践 + +1. **任务驱动** - 使用 ai-proj 管理所有开发任务 +2. **分层清晰** - Controller → Service → Repository +3. **接口先行** - 先定义接口再实现 +4. **小步提交** - 频繁提交,每次做一件事 +5. **测试覆盖** - 核心逻辑必须有测试 +6. **文档同步** - 代码变更同步更新文档 diff --git a/plugins/dev-plugin/.claude-plugin/plugin.json b/plugins/dev-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..15b3183 --- /dev/null +++ b/plugins/dev-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "dev-plugin", + "description": "Plugin for dev", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/dev-plugin/skills/SKILL.md b/plugins/dev-plugin/skills/SKILL.md new file mode 100644 index 0000000..9552b15 --- /dev/null +++ b/plugins/dev-plugin/skills/SKILL.md @@ -0,0 +1,314 @@ +--- +name: dev +description: 软件开发技能组入口。整合架构设计(dev-arch)、编码实现(dev-coding)、测试(dev-test)三个子技能,提供完整的软件开发工作流。支持全栈开发:Go、Vue、React、iOS、Android、小程序等。 +--- + +# 软件开发 Skill (dev) + +## 概述 + +dev 是一个技能组入口,整合了软件开发的三个核心阶段: + +| 子技能 | 用途 | 触发词 | +|--------|------|--------| +| **dev-arch** | 架构设计、技术选型、系统设计 | 架构、设计、技术方案 | +| **dev-coding** | 编码实现、功能开发、代码审查 | 编码、开发、实现 | +| **dev-test** | 单元测试、集成测试、E2E测试 | 测试、test、覆盖率 | + +--- + +## 开发工作流 + +``` +需求分析 → 架构设计 → 编码实现 → 测试验证 → 部署上线 + ↓ ↓ ↓ + dev-arch dev-coding dev-test +``` + +### 典型流程 + +1. **架构设计** (dev-arch) + - 需求分析 + - 技术选型 + - 系统设计文档 + - 架构评审 + +2. **编码实现** (dev-coding) + - 任务分解 + - 功能开发 + - 代码审查 + - 文档记录 + +3. **测试验证** (dev-test) + - 单元测试 + - 集成测试 + - E2E 测试 + - 覆盖率分析 + +--- + +## 支持的项目类型 + +### 当前项目生态 + +| 项目 | 类型 | 技术栈 | +|------|------|--------| +| **TWMS** | 仓储物流 | Go + Vue 3 + MySQL | +| **AI-Proj** | 项目管理 | Go + React + PostgreSQL + iOS + Android | +| **DICIAI** | 进销存SaaS | Go + Vue 3 + MySQL + Android PDA | + +### 技术栈矩阵 + +| 端 | 语言/框架 | 工具 | +|-----|----------|------| +| **后端** | Go (Gin + GORM) | MySQL/PostgreSQL, Redis, Docker | +| **Web前端** | Vue 3 / React 18 | TypeScript, Vite/CRA, Ant Design | +| **iOS** | Swift + SwiftUI | Xcode, XCTest | +| **Android** | Kotlin + Compose | Gradle, Hilt, Room | +| **PDA** | Android 原生 | 扫码枪集成, 离线存储 | +| **MCP** | TypeScript | Node.js, MCP SDK | + +--- + +## ai-proj 任务管理集成 + +所有开发工作都通过 ai-proj CLI 进行任务管理: + +### 快速开始 + +```bash +# 1. 查看待办任务 +ai-proj task list --status in_progress,todo + +# 2. 启动任务 +ai-proj task start --id + +# 3. 完成任务 +ai-proj task complete --id + +# 4. 记录文档 +ai-proj task append-doc --id --content "实现说明" +``` + +### 任务分解 + +```bash +# 创建主任务 +ai-proj task create --title "功能名称" + +# 创建子任务 +ai-proj task create --title "架构设计" --parent-id +ai-proj task create --title "功能开发" --parent-id +ai-proj task create --title "测试验证" --parent-id +``` + +--- + +## 常用命令速查 + +### Go 后端 + +```bash +# 构建 +make build + +# 运行 +./_output/main --config ./configs/config.yaml + +# 测试 +make test +make cover +``` + +### Vue 前端 + +```bash +# 开发 +npm run dev + +# 构建 +npm run build:prod + +# 检查 +npm run lint:eslint +``` + +### React 前端 + +```bash +# 开发 +npm start + +# 构建 +npm run build + +# 测试 +npm test +npm run test:e2e +``` + +### iOS + +```bash +# 构建 +xcodebuild -scheme ProjectName -configuration Debug + +# 测试 +xcodebuild test -scheme ProjectName +``` + +### Android + +```bash +# 构建 +./gradlew assembleDebug +./gradlew assembleRelease + +# 测试 +./gradlew test +./gradlew connectedAndroidTest +``` + +--- + +## Git 工作流 + +### 提交规范 + +| 类型 | 说明 | +|------|------| +| feat | 新功能 | +| fix | Bug 修复 | +| docs | 文档 | +| refactor | 重构 | +| test | 测试 | +| chore | 杂项 | + +### 分支策略 + +```bash +# 功能开发 +git checkout -b feature/功能名称 + +# 提交 +git commit -m "feat: 功能描述" + +# 推送 +git push origin feature/功能名称 + +# 合并 +git checkout main +git merge feature/功能名称 +``` + +### 双电脑同步 (au-dev / cn-dev) + +```bash +# 离开时 +git add -A +git commit -m "WIP: sync from $(hostname)" +git push origin $(git branch --show-current) + +# 到达时 +git fetch origin +git pull origin $(git branch --show-current) +``` + +--- + +## Docker 部署 + +### 标准配置 + +```yaml +services: + backend: + build: ./backend + ports: + - "8080:8080" + depends_on: + - db + - redis + + frontend: + build: ./frontend + ports: + - "80:80" + + db: + image: mysql:8.0 + # 或 postgres:15 + + redis: + image: redis:alpine +``` + +### 常用端口 + +| 服务 | 端口 | +|------|------| +| Backend | 8080 / 9099 | +| Frontend | 80 / 3000 | +| MySQL | 3306 | +| PostgreSQL | 5432 | +| Redis | 6379 | + +--- + +## 子技能详情 + +### dev-arch (架构设计) + +用于系统设计阶段: +- 需求分析 +- 技术选型 +- 架构设计文档 +- API 设计 +- 数据库设计 +- 架构评审 + +### dev-coding (编码实现) + +用于开发实现阶段: +- Go 后端开发 +- Vue/React 前端开发 +- iOS/Android 移动开发 +- PDA 应用开发 +- MCP 桥接开发 +- 代码审查 + +### dev-test (测试) + +用于测试验证阶段: +- 单元测试 +- 集成测试 +- E2E 测试 +- UI 测试 +- 覆盖率分析 + +--- + +## 最佳实践 + +1. **任务驱动** - 使用 ai-proj 管理所有开发任务 +2. **设计先行** - 复杂功能先设计后编码 +3. **分层清晰** - Controller → Service → Repository +4. **小步提交** - 频繁提交,每次做一件事 +5. **测试覆盖** - 核心逻辑必须有测试 +6. **文档同步** - 代码变更同步更新文档 +7. **代码审查** - 重要变更必须审查 + +--- + +## 何时使用哪个子技能 + +| 场景 | 推荐技能 | +|------|----------| +| 新功能设计 | dev-arch | +| 技术方案评审 | dev-arch | +| 功能开发实现 | dev-coding | +| Bug 修复 | dev-coding | +| 编写测试 | dev-test | +| 测试覆盖率提升 | dev-test | +| 代码审查 | dev-coding | +| 性能优化 | dev-arch + dev-coding | diff --git a/plugins/dev-test-plugin/.claude-plugin/plugin.json b/plugins/dev-test-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..de2c398 --- /dev/null +++ b/plugins/dev-test-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "dev-test-plugin", + "description": "软件测试技能。用于单元测试、集成测试、E2E测试、测试用例设计。支持 Go、Vue、React、iOS、Android 等多平台测试。", + "version": "2.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/dev-test-plugin/skills/dev-test/SKILL.md b/plugins/dev-test-plugin/skills/dev-test/SKILL.md new file mode 100644 index 0000000..8762321 --- /dev/null +++ b/plugins/dev-test-plugin/skills/dev-test/SKILL.md @@ -0,0 +1,141 @@ +--- +name: dev-test +description: 软件测试技能。用于单元测试、集成测试、E2E测试、测试用例设计。支持 Go、Vue、React、iOS、Android 等多平台测试。 +--- + +# 软件测试 Skill (dev-test) + +## 子文件索引 + +| 文件 | 内容 | +|------|------| +| `go-testing.md` | Go 后端测试 (testify + test DB + httptest)。**biz 层禁止 mock,必须用真实 PostgreSQL test DB** | +| `frontend-testing.md` | Vue (Vitest) + React (Jest) 前端测试 | +| `ios-testing.md` | iOS 测试 (XCTest + Swift Concurrency) | +| `android-testing.md` | Android 测试 (JUnit + Espresso + Compose) | +| `e2e-testing.md` | E2E Playwright + Coolbuy PaaS 集成测试 | + +--- + +## 测试金字塔 + +``` + /\ + / \ E2E (少量) + /----\ + / \ 集成测试 (适量) + /--------\ + / \ 单元测试 (大量) + /------------\ +``` + +| 类型 | 范围 | 速度 | 数量 | +|------|------|------|------| +| 单元测试 | 函数/方法 | 快 | 多 | +| 集成测试 | 模块交互 | 中 | 适量 | +| E2E 测试 | 完整流程 | 慢 | 少 | + +--- + +## 测试命令速查 + +| 平台 | 命令 | 详见 | +|------|------|------| +| Go | `make test` / `go test ./...` | `go-testing.md` | +| Vue | `npm run test` | `frontend-testing.md` | +| React | `npm test` | `frontend-testing.md` | +| iOS | `xcodebuild test` | `ios-testing.md` | +| Android | `./gradlew test` | `android-testing.md` | +| E2E (通用) | `npm run test:e2e` | `e2e-testing.md` | +| E2E (Coolbuy PaaS) | `make e2e` | `e2e-testing.md` | + +--- + +## Chrome DevTools MCP (AI 浏览器调试) + +> Google 官方 MCP 服务器,让 AI 助手直接控制和检查 Chrome 浏览器。 + +```bash +claude mcp add chrome-devtools npx chrome-devtools-mcp@latest +``` + +| 分类 | 工具 | 说明 | +|------|------|------| +| **输入** | `click` / `fill` / `fill_form` / `hover` / `upload_file` | 页面交互 | +| **导航** | `navigate_page` / `new_page` / `list_pages` / `wait_for` | 页面导航 | +| **调试** | `evaluate_script` / `list_console_messages` / `take_screenshot` | 调试工具 | +| **网络** | `list_network_requests` / `get_network_request` | 网络分析 | +| **性能** | `performance_start_trace` / `performance_stop_trace` | 性能追踪 | +| **模拟** | `emulate_device` / `throttle_network` / `throttle_cpu` | 环境模拟 | + +--- + +## 测试用例设计 + +### 等价类划分 + +| 输入 | 有效类 | 无效类 | +|------|--------|--------| +| 用户名 | 3-64字符 | <3, >64 | +| 年龄 | 0-150 | <0, >150 | +| 邮箱 | 有效格式 | 无效格式 | + +### 边界值 + +``` +范围 [1, 100]: +测试点: 0, 1, 2, 99, 100, 101 +``` + +### 测试用例模板 + +```markdown +## TC-001: 用户登录成功 + +**前置条件**: 用户已注册 +**步骤**: +1. 输入有效用户名 +2. 输入有效密码 +3. 点击登录 + +**预期**: 跳转到首页 +**优先级**: P0 +``` + +--- + +## 覆盖率目标 + +| 类型 | 目标 | +|------|------| +| 行覆盖 | >80% | +| 分支覆盖 | >70% | +| 函数覆盖 | >90% | + +--- + +## 与 ai-proj 集成 + +```bash +# 创建测试任务 +ai-proj task create --title "[模块] 单元测试" + +# 记录测试结果 +ai-proj task append-doc --id --content "# 测试报告 +- 覆盖率: 85% +- 通过: 42 +- 失败: 0" +``` + +--- + +## 最佳实践 + +1. **测试金字塔** - 多单元测试,少 E2E +2. **测试隔离** - 每个测试独立 +3. **命名清晰** - 描述预期行为 +4. **快速反馈** - 测试要快 +5. **持续集成** - 每次提交运行 +6. **Biz 层禁止 Mock** - biz/service 层必须使用真实 PostgreSQL test DB + 真实 store,mock 等于没测 +7. **Mock 仅限 Handler 层** - handler 层可以 mock biz 接口 + httptest +7. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md` diff --git a/plugins/dev-test-plugin/skills/dev-test/android-testing.md b/plugins/dev-test-plugin/skills/dev-test/android-testing.md new file mode 100644 index 0000000..e95ea96 --- /dev/null +++ b/plugins/dev-test-plugin/skills/dev-test/android-testing.md @@ -0,0 +1,145 @@ +# Android 测试 (JUnit + Espresso) + +## 运行测试 + +```bash +# 单元测试 +./gradlew test + +# UI 测试 +./gradlew connectedAndroidTest +``` + +## 单元测试 (JUnit) + +```kotlin +class TaskViewModelTest { + @get:Rule + val instantTaskRule = InstantTaskExecutorRule() + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private lateinit var viewModel: TaskViewModel + private lateinit var repository: FakeTaskRepository + + @Before + fun setup() { + repository = FakeTaskRepository() + viewModel = TaskViewModel(repository) + } + + @Test + fun `fetchTasks updates state`() = runTest { + // Arrange + repository.addTasks(listOf( + Task(1, "Task 1"), + Task(2, "Task 2") + )) + + // Act + viewModel.fetchTasks() + + // Assert + val tasks = viewModel.tasks.first() + assertEquals(2, tasks.size) + } + + @Test + fun `createTask adds task`() = runTest { + // Act + viewModel.createTask("New Task") + + // Assert + val tasks = viewModel.tasks.first() + assertTrue(tasks.any { it.title == "New Task" }) + } +} +``` + +## UI 测试 (Espresso) + +```kotlin +@RunWith(AndroidJUnit4::class) +@LargeTest +class TaskListActivityTest { + + @get:Rule + val activityRule = ActivityScenarioRule(TaskListActivity::class.java) + + @Test + fun displayTaskList() { + onView(withId(R.id.taskList)) + .check(matches(isDisplayed())) + } + + @Test + fun clickTask_opensDetail() { + onView(withId(R.id.taskList)) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + + onView(withId(R.id.taskDetail)) + .check(matches(isDisplayed())) + } + + @Test + fun addTask_showsInList() { + // Click add button + onView(withId(R.id.addButton)).perform(click()) + + // Enter title + onView(withId(R.id.titleInput)) + .perform(typeText("New Task"), closeSoftKeyboard()) + + // Save + onView(withId(R.id.saveButton)).perform(click()) + + // Verify in list + onView(withText("New Task")) + .check(matches(isDisplayed())) + } +} +``` + +## Compose UI 测试 + +```kotlin +@RunWith(AndroidJUnit4::class) +class TaskListScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun taskList_displays() { + val tasks = listOf( + Task(1, "Task 1"), + Task(2, "Task 2") + ) + + composeRule.setContent { + TaskListScreen(tasks = tasks) + } + + composeRule.onNodeWithText("Task 1").assertExists() + composeRule.onNodeWithText("Task 2").assertExists() + } + + @Test + fun taskClick_callsOnClick() { + var clickedId: Int? = null + val tasks = listOf(Task(1, "Task 1")) + + composeRule.setContent { + TaskListScreen( + tasks = tasks, + onTaskClick = { clickedId = it.id } + ) + } + + composeRule.onNodeWithText("Task 1").performClick() + + assertEquals(1, clickedId) + } +} +``` diff --git a/plugins/dev-test-plugin/skills/dev-test/e2e-testing.md b/plugins/dev-test-plugin/skills/dev-test/e2e-testing.md new file mode 100644 index 0000000..af0a4c6 --- /dev/null +++ b/plugins/dev-test-plugin/skills/dev-test/e2e-testing.md @@ -0,0 +1,169 @@ +# E2E 测试 (Playwright) + +## 通用 Playwright 配置 + +```typescript +// playwright.config.ts +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30000, + use: { + baseURL: 'http://localhost:3000', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + ], +}) +``` + +## 通用 E2E 测试示例 + +```typescript +// login.spec.ts +import { test, expect } from '@playwright/test' + +test.describe('Login', () => { + test('successful login', async ({ page }) => { + await page.goto('/login') + + await page.fill('[data-testid="username"]', 'testuser') + await page.fill('[data-testid="password"]', 'password') + await page.click('[data-testid="submit"]') + + await expect(page).toHaveURL('/dashboard') + await expect(page.locator('.welcome')).toContainText('testuser') + }) + + test('invalid credentials', async ({ page }) => { + await page.goto('/login') + + await page.fill('[data-testid="username"]', 'wrong') + await page.fill('[data-testid="password"]', 'wrong') + await page.click('[data-testid="submit"]') + + await expect(page.locator('.error')).toBeVisible() + }) +}) + +test.describe('Task Management', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login') + await page.fill('[data-testid="username"]', 'testuser') + await page.fill('[data-testid="password"]', 'password') + await page.click('[data-testid="submit"]') + }) + + test('create task', async ({ page }) => { + await page.click('[data-testid="new-task"]') + await page.fill('[data-testid="task-title"]', 'E2E Test Task') + await page.click('[data-testid="save"]') + + await expect(page.locator('text=E2E Test Task')).toBeVisible() + }) +}) +``` + +--- + +## Coolbuy PaaS E2E 集成测试 + +> Playwright 全链路 E2E 测试,独立环境(DB + 端口),可与 dev 服务并行运行。 + +### 环境架构 + +| 服务 | Dev 端口 | E2E 端口 | DB | +|------|---------|---------|-----| +| Auth Service | 7089 | 7189 | coolbuy_paas_e2e | +| Foundation Service | 7090 | 7190 | coolbuy_paas_e2e | +| ERP Service | 7091 | 7191 | coolbuy_paas_e2e | +| Web Frontend | 4000 | 4010 | - | + +**E2E DB 初始化**(首次/重置): +```bash +psql -U coolbuy-dev -d postgres -c "DROP DATABASE IF EXISTS coolbuy_paas_e2e;" +psql -U coolbuy-dev -d postgres -c "CREATE DATABASE coolbuy_paas_e2e OWNER \"coolbuy-dev\";" +pg_dump -U coolbuy-dev coolbuy_paas_local | psql -U coolbuy-dev coolbuy_paas_e2e +``` + +### 启动 / 停止 E2E 服务 + +```bash +make e2e-start # 启动全部 E2E 服务(auth/foundation/erp/web) +make e2e-stop # 停止全部 E2E 服务 +make e2e-reset # 重置 DB 后启动 +make e2e # 启动服务 + 运行全部测试 +``` + +脚本位置:`scripts/start-e2e-services.sh` / `scripts/stop-e2e-services.sh` + +### 运行测试 + +```bash +cd web + +# 全部测试(无头模式) +npx playwright test + +# 带 UI 调试 +npx playwright test --headed + +# 单个文件 +npx playwright test tests/product-crud.spec.ts + +# 查看 HTML 报告(注意:会启动 HTTP server,需 Ctrl+C 退出) +npx playwright show-report +``` + +### Auth 自动登录 + +`tests/auth.setup.ts` 优先点击快速登录按钮(`VITE_ENABLE_QUICK_LOGIN=true`),降级为表单登录: + +```typescript +// 快速登录(E2E 环境默认开启) +const quickLoginBtn = page.locator('button, a').filter({ hasText: /李宁|lining|ID:2/i }).first(); +if (await quickLoginBtn.isVisible({ timeout: 3000 })) { + await quickLoginBtn.click(); +} else { + // 降级:填写 lining_admin / admin123,验证码任意 4 位(SkipVerify=true) +} +await page.waitForURL(/\/tenant/, { timeout: 15000 }); +await page.context().storageState({ path: authFile }); +``` + +Session 保存至 `.auth/user.json`,后续测试自动复用,无需重复登录。 + +### 配置文件 + +| 文件 | 说明 | +|------|------| +| `web/.env.e2e` | E2E 环境变量(端口 / 快速登录开关) | +| `web/playwright.config.ts` | baseURL=localhost:4010,reporter=[html, list] | +| `auth-service/api/etc/auth-api-e2e.yaml` | E2E auth 配置(SkipVerify=true) | +| `foundation-service/api/etc/foundation-api-e2e.yaml` | E2E foundation 配置 | +| `erp-service/configs/config.e2e.yaml` | E2E ERP 配置 | + +### 测试结果解读 + +当前 **113 tests — 103 ✅ / 10 ❌**,已知失败项: + +| 失败原因 | 涉及测试 | +|---------|---------| +| `/tenant/order/business` 路由 404(页面未实现) | 业务订单列表 × 3、订单模块导航 | +| 预警管理无搜索表单组件 | 预警管理搜索/筛选 | +| 库存管理页无 table/empty 状态 | 仓库管理-库存管理 | +| 待审批订单 networkidle 超时(>60s) | 待审批订单列表 | +| 数据权限/字段权限页渲染异常 | 系统管理 × 2 | +| 业务 CRM 页渲染异常 | 业务 CRM | + +### 常见问题 + +| 问题 | 解决 | +|------|------| +| `Executable doesn't exist` | `npx playwright install chromium` | +| 端口 4010 被占用 | `make e2e-stop` 后重试 | +| GORM migration 失败 | 检查 DB 是否有旧约束名,手动 DROP CONSTRAINT 后重启服务 | +| HTML 报告进程不退出 | Playwright 在 report 模式会启动 HTTP server,用 `Ctrl+C` 停止 | diff --git a/plugins/dev-test-plugin/skills/dev-test/frontend-testing.md b/plugins/dev-test-plugin/skills/dev-test/frontend-testing.md new file mode 100644 index 0000000..e260e77 --- /dev/null +++ b/plugins/dev-test-plugin/skills/dev-test/frontend-testing.md @@ -0,0 +1,174 @@ +# 前端测试 (Vue + React) + +## Vue 前端测试 + +### 测试框架 + +- **Vitest**: 测试运行器 +- **Vue Test Utils**: 组件测试 +- **MSW**: API Mock + +### 运行测试 + +```bash +npm run test +npm run test:watch +npm run test:coverage +``` + +### 组件测试 + +```typescript +// UserList.test.ts +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import UserList from './UserList.vue' + +describe('UserList', () => { + it('renders user list', () => { + const wrapper = mount(UserList, { + props: { + users: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' } + ] + } + }) + + expect(wrapper.findAll('.user-item')).toHaveLength(2) + expect(wrapper.text()).toContain('Alice') + }) + + it('emits select event', async () => { + const wrapper = mount(UserList, { + props: { users: [{ id: 1, name: 'Alice' }] } + }) + + await wrapper.find('.user-item').trigger('click') + + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')[0]).toEqual([1]) + }) + + it('shows empty state', () => { + const wrapper = mount(UserList, { + props: { users: [] } + }) + + expect(wrapper.find('.empty-state').exists()).toBe(true) + }) +}) +``` + +### API Mock (MSW) + +```typescript +// mocks/handlers.ts +import { rest } from 'msw' + +export const handlers = [ + rest.get('/api/v1/users', (req, res, ctx) => { + return res(ctx.json({ + code: 0, + data: { + total: 2, + list: [{ id: 1, name: 'Alice' }] + } + })) + }), + + rest.post('/api/v1/users', async (req, res, ctx) => { + const body = await req.json() + return res(ctx.json({ + code: 0, + data: { id: 3, ...body } + })) + }) +] +``` + +--- + +## React 前端测试 + +### 测试框架 + +- **Jest**: 测试运行器 +- **React Testing Library**: 组件测试 +- **Playwright**: E2E 测试 + +### 运行测试 + +```bash +npm test +npm run test:e2e +npm run test:e2e:headed +``` + +### 组件测试 + +```typescript +// TaskCard.test.tsx +import { render, screen, fireEvent } from '@testing-library/react' +import TaskCard from './TaskCard' + +describe('TaskCard', () => { + const mockTask = { + id: 1, + title: 'Test Task', + status: 'todo', + priority: 'high' + } + + it('renders task title', () => { + render() + expect(screen.getByText('Test Task')).toBeInTheDocument() + }) + + it('displays priority', () => { + render() + expect(screen.getByText('high')).toHaveClass('priority-high') + }) + + it('calls onClick', () => { + const handleClick = jest.fn() + render() + + fireEvent.click(screen.getByRole('article')) + + expect(handleClick).toHaveBeenCalledWith(mockTask) + }) +}) +``` + +### Hook 测试 + +```typescript +// useTimer.test.ts +import { renderHook, act } from '@testing-library/react-hooks' +import { useTimer } from './useTimer' + +describe('useTimer', () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => jest.useRealTimers()) + + it('starts timer', () => { + const { result } = renderHook(() => useTimer()) + + act(() => result.current.start()) + + expect(result.current.isRunning).toBe(true) + }) + + it('increments time', () => { + const { result } = renderHook(() => useTimer()) + + act(() => { + result.current.start() + jest.advanceTimersByTime(3000) + }) + + expect(result.current.seconds).toBe(3) + }) +}) +``` diff --git a/plugins/dev-test-plugin/skills/dev-test/go-testing.md b/plugins/dev-test-plugin/skills/dev-test/go-testing.md new file mode 100644 index 0000000..aa6750c --- /dev/null +++ b/plugins/dev-test-plugin/skills/dev-test/go-testing.md @@ -0,0 +1,208 @@ +# Go 后端测试 + +## 测试框架 + +- **testify**: 断言和套件 +- **httptest**: HTTP 测试 +- **gomock**: Mock 生成(仅用于 handler 层) + +## ⚠️ Biz 层测试规则:禁止使用 Mock + +**Biz/Service 层测试必须使用真实 PostgreSQL test DB,不允许使用 mock store。** + +Mock store 只是在测试你的 mock 实现,无法验证真实的 SQL 行为、事务、FK 约束等。 + +| 层 | 测试方式 | 原因 | +|----|---------|------| +| model/store | **test DB** (PostgreSQL) | 验证真实 SQL/ORM 行为 | +| biz/service | **test DB** (PostgreSQL) + 真实 store | 验证业务逻辑 + 真实数据交互 | +| handler | **mock biz + httptest** | 只测 HTTP 路由和参数绑定 | + +```go +// ✅ 正确 — biz 层使用真实 test DB + 真实 store +func setupBiz(t *testing.T) (*SomeBiz, *gorm.DB) { + db := newTestDB(t) + s := store.NewSomeStore(db) + biz := NewSomeBiz(s) + t.Cleanup(func() { + db.Exec("DELETE FROM some_table WHERE tenant_id = ?", testTenantID) + }) + return biz, db +} + +// ❌ 错误 — biz 层使用 mock store(等于没测) +mockStore := store.NewMockIStore(ctrl) +mockStore.EXPECT().Get(gomock.Any(), id).Return(fakeData, nil) +biz := NewSomeBiz(mockStore) +``` + +### testdb_test.go 模板 + +```go +package biz + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +const testTenantID int64 = 99 + +func newTestDB(t *testing.T, models ...interface{}) *gorm.DB { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_DSN") + if dsn == "" { + dsn = "host=localhost user=coolbuy-dev dbname=coolbuy_paas_test sslmode=disable" + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(models...)) + return db +} +``` + +## 运行测试 + +```bash +# 所有测试 +go test ./... +make test + +# 带覆盖率 +make cover +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out + +# 特定包 +go test -v ./internal/twms/biz/... + +# 特定函数 +go test -v -run TestFunctionName ./... +``` + +## Biz 层单元测试模板(真实 DB) + +```go +package biz + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "project/internal/user/model" + "project/internal/user/store" +) + +func setupUserBiz(t *testing.T) (*UserBiz, *gorm.DB) { + db := newTestDB(t, &model.User{}) + s := store.NewUserStore(db) + biz := NewUserBiz(s) + t.Cleanup(func() { + db.Exec("DELETE FROM users WHERE tenant_id = ?", testTenantID) + }) + return biz, db +} + +func createTestUser(t *testing.T, db *gorm.DB, username string) *model.User { + t.Helper() + user := &model.User{TenantID: testTenantID, Username: username, Email: username + "@test.com"} + require.NoError(t, db.Create(user).Error) + return user +} + +func TestUserBiz_Get(t *testing.T) { + biz, db := setupUserBiz(t) + user := createTestUser(t, db, "john") + + result, err := biz.Get(context.Background(), user.ID) + + assert.NoError(t, err) + assert.Equal(t, "john", result.Username) +} + +func TestUserBiz_Get_NotFound(t *testing.T) { + biz, _ := setupUserBiz(t) + + _, err := biz.Get(context.Background(), 99999) + + assert.Error(t, err) +} +``` + +## 表驱动测试 + +```go +func TestValidateUsername(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid", "john_doe", false}, + {"too_short", "ab", true}, + {"too_long", strings.Repeat("a", 65), true}, + {"special_chars", "user@name", true}, + {"empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateUsername(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} +``` + +## HTTP Handler 测试 + +```go +func TestUserController_List(t *testing.T) { + gin.SetMode(gin.TestMode) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockBiz := biz.NewMockIBiz(ctrl) + mockUserBiz := biz.NewMockUserBiz(ctrl) + + mockBiz.EXPECT().Users().Return(mockUserBiz).AnyTimes() + mockUserBiz.EXPECT().List(gomock.Any(), gomock.Any()).Return(&v1.ListUsersResponse{ + Total: 1, + Users: []*v1.User{{Id: 1, Username: "test"}}, + }, nil) + + controller := NewUserController(mockBiz) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/v1/users?page=1&limit=10", nil) + + controller.List(c) + + assert.Equal(t, http.StatusOK, w.Code) +} +``` + +## Mock 生成 + +```bash +# 生成 Mock +mockgen -source=internal/twms/store/store.go \ + -destination=internal/twms/store/mock_store.go \ + -package=store + +# go:generate 方式 +//go:generate mockgen -source=store.go -destination=mock_store.go -package=store +``` diff --git a/plugins/dev-test-plugin/skills/dev-test/ios-testing.md b/plugins/dev-test-plugin/skills/dev-test/ios-testing.md new file mode 100644 index 0000000..49b9071 --- /dev/null +++ b/plugins/dev-test-plugin/skills/dev-test/ios-testing.md @@ -0,0 +1,157 @@ +# iOS 测试 (XCTest + Swift Concurrency) + +## 测试框架 + +- **XCTest**: Apple 官方测试框架 +- **Swift Testing**: Swift 6 新测试框架 (可选) +- **ViewInspector**: SwiftUI 视图测试 (第三方) + +## 运行测试 + +```bash +# 全部测试 +xcodebuild test \ + -scheme AI-Proj-iOS \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -quiet + +# 特定测试类 +xcodebuild test \ + -scheme AI-Proj-iOS \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -only-testing:AI-Proj-iOSTests/DashboardViewModelTests + +# 覆盖率 +xcodebuild test \ + -scheme AI-Proj-iOS \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -enableCodeCoverage YES +``` + +## 项目测试结构 (AI-Proj-iOS) + +``` +AI-Proj-iOSTests/ +├── Mocks/ +│ ├── MockServices.swift # Mock 服务协议实现 +│ └── MockNetworkService.swift +├── ViewModels/ +│ ├── DashboardViewModelTests.swift +│ ├── TaskViewModelTests.swift +│ └── RequirementViewModelTests.swift +├── Services/ +│ ├── TaskServiceTests.swift +│ └── DashboardAggregationServiceTests.swift +├── Models/ +│ └── ModelDecodingTests.swift +└── Utilities/ + └── DateFormatterTests.swift +``` + +## 关键模式 + +### 1. Mock 服务 — Result 注入 + +```swift +class MockTaskService: TaskServiceProtocol { + var fetchTasksResult: Result = .success(.mock) + + func fetchTasks(...) async throws -> TaskListResponse { + switch fetchTasksResult { + case .success(let response): return response + case .failure(let error): throw error + } + } +} +``` + +所有 Mock 服务统一用 `Result` 属性控制成功/失败返回。 + +### 2. ViewModel 测试 — @MainActor + async + +```swift +@MainActor +final class DashboardViewModelTests: XCTestCase { + var sut: DashboardViewModel! + var mockService: MockDashboardAggregationService! + + override func setUp() { + super.setUp() + mockService = MockDashboardAggregationService() + sut = DashboardViewModel(dashboardService: mockService) + } + + override func tearDown() { + sut = nil; mockService = nil + super.tearDown() + } + + func testLoadDashboardData_Success() async { + mockService.fetchDashboardDataResult = .success(expectedData) + await sut.loadDashboardData() + XCTAssertFalse(sut.isLoading) + XCTAssertEqual(sut.todayStats.completedTasks, 5) + } +} +``` + +要点:`@MainActor` + `async` 测试方法 + setUp/tearDown 重置。 + +### 3. Mock 数据工厂 — 静态 `.mock()` 方法 + +```swift +extension TaskModel { + static func mock(id: Int = 1, status: TaskStatus = .todo) -> TaskModel { + TaskModel(id: id, title: "Mock Task", status: status, ...) + } +} + +extension TaskListResponse { + static var mock: TaskListResponse { + TaskListResponse(tasks: [.mock(id: 1), .mock(id: 2)], total: 2, page: 1, pageSize: 20) + } +} +``` + +### 4. 模型解码测试 — JSON → Model + +```swift +func testTaskModel_DecodesFromJSON() throws { + let json = """ + { "id": 123, "status": "in_progress", "priority": "high", ... } + """.data(using: .utf8)! + + let task = try decoder.decode(TaskModel.self, from: json) + XCTAssertEqual(task.status, .inProgress) +} +``` + +### 5. SwiftUI 视图测试 — ViewInspector + +```swift +extension EnhancedStatsSection: Inspectable {} + +func testStatsSection_DisplaysCorrectValues() throws { + let view = EnhancedStatsSection(stats: .mock) + let text = try view.inspect().find(text: "5") + XCTAssertNotNil(text) +} +``` + +## 最佳实践 + +1. **@MainActor** — ViewModel 测试必须在主线程 +2. **Mock 所有依赖** — 协议抽象 + Result 注入 +3. **async/await** — 避免 XCTestExpectation 回调 +4. **数据工厂** — `.mock()` 静态方法,参数带默认值 +5. **隔离测试** — setUp/tearDown 重置所有状态 +6. **命名** — `test_` 格式 + +## Xcode 快捷键 + +| 快捷键 | 操作 | +|--------|------| +| `Cmd + U` | 运行所有测试 | +| `Ctrl + Opt + Cmd + U` | 运行当前测试方法 | +| `Ctrl + Opt + Cmd + G` | 重新运行上次测试 | +| `Cmd + 6` | Test Navigator | diff --git a/plugins/dotfiles-plugin/.claude-plugin/plugin.json b/plugins/dotfiles-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..ee2a53e --- /dev/null +++ b/plugins/dotfiles-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "dotfiles-plugin", + "description": "macOS 新机快速部署。用于 dotfiles 配置管理、install.sh 脚本维护、Claude Code 插件配置、MCP Server 配置。当用户提到新机部署、dotfiles、环境配置相关任务时自动激活。", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/dotfiles-plugin/skills/SKILL.md b/plugins/dotfiles-plugin/skills/SKILL.md new file mode 100644 index 0000000..72e54da --- /dev/null +++ b/plugins/dotfiles-plugin/skills/SKILL.md @@ -0,0 +1,469 @@ +--- +name: dotfiles +description: macOS 新机快速部署。用于 dotfiles 配置管理、install.sh 脚本维护、Claude Code 插件配置、MCP Server 配置。当用户提到新机部署、dotfiles、环境配置相关任务时自动激活。 +--- + +# dotfiles - macOS 新机快速部署 + +自动化 macOS 开发环境配置,包括 Homebrew、Claude Code、插件、MCP Server 等。 + +## 核心功能 + +1. **配置文件管理** - dotfiles 符号链接、Git 配置同步 +2. **Claude Code 配置** - 插件批量安装、MCP Server 配置模板 +3. **开发工具安装** - Homebrew、mise、SSH 配置 +4. **环境一致性** - 多台电脑配置同步 + +## 子文档索引 + +| 文档 | 说明 | +|------|------| +| [config-formats.md](docs/config-formats.md) | 配置文件格式规范(必读) | +| [testing.md](docs/testing.md) | 部署验证与测试方法 | +| [troubleshooting.md](docs/troubleshooting.md) | 常见问题与解决方案 | + +--- + +## 快速开始 + +### 1. 克隆 dotfiles + +```bash +git clone git@gitea.pipexerp.com:huangjun/dotfiles.git ~/.dotfiles +cd ~/.dotfiles +``` + +### 2. 执行安装脚本 + +```bash +./install.sh +``` + +**注意**: +- 需要 sudo 权限(安装 Homebrew) +- 需要手动输入密码(非交互式 SSH 无法自动化) +- 首次运行约 10-15 分钟 + +### 3. 配置 MCP Server + +```bash +# 1. 复制环境变量模板 +cp ~/.dotfiles/claude/.env.example ~/.dotfiles/claude/.env + +# 2. 编辑填入真实 Token +vim ~/.dotfiles/claude/.env + +# 3. 删除现有 settings.json(如果已存在) +rm ~/.claude/settings.json + +# 4. 重新运行 install.sh 生成配置 +cd ~/.dotfiles && ./install.sh +``` + +--- + +## 配置文件结构 + +``` +~/.dotfiles/ +├── install.sh # 主安装脚本 +├── .zshrc # Zsh 配置 +├── .gitconfig # Git 配置 +├── Brewfile # Homebrew 软件清单 +├── claude/ +│ ├── plugins-list.yaml # Claude Code 插件清单 +│ ├── settings.json.template # MCP Server 配置模板 +│ ├── .env.example # 环境变量示例 +│ └── .env # 实际环境变量(不提交) +├── claude-skills/ # Claude Code 技能目录 +└── mise/ + └── config.toml # mise 全局配置 +``` + +--- + +## 关键组件说明 + +### 1. plugins-list.yaml + +**作用**:Claude Code 插件批量安装清单 + +**格式要求**(⚠️ 必须遵守): +```yaml +# ✅ 正确格式 - 统一 plugins: 段落 +plugins: + - name: context7 + marketplace: claude-plugins-official + description: 文档检索与上下文管理 + + - name: req-plugin + marketplace: claude-marketplace + description: 需求工作流管理 + +# 市场源 URL 配置 +marketplace: + claude-marketplace: + url: git@gitea.pipexerp.com:huangjun/claude-marketplace.git + type: git +``` + +**常见错误**: +```yaml +# ❌ 错误格式 - 分段格式(install.sh 无法解析) +official_plugins: + - name: context7 + marketplace: claude-plugins-official + +private_plugins: + - name: req-plugin + marketplace: claude-marketplace +``` + +**详细规范**:见 [config-formats.md](docs/config-formats.md#plugins-listyaml) + +### 2. settings.json.template + +**作用**:MCP Server 配置模板,由 install.sh 使用 envsubst 渲染 + +**格式要求**(⚠️ 必须遵守): +```json +{ + "mcpServers": { + "ai-proj": { + "command": "node", + "args": [ + "${HOME}/coding/qiudl/new-ai-proj/mcp-task-bridge/dist/index.js" + ], + "env": { + "TASK_API_BASE": "${AI_PROJ_API_BASE}", + "TASK_API_TOKEN": "${AI_PROJ_API_TOKEN}" + } + } + } +} +``` + +**占位符格式**: +- ✅ 使用 `${VAR}` (envsubst 兼容) +- ❌ 禁止使用 `{{VAR}}` (envsubst 不支持) + +**详细规范**:见 [config-formats.md](docs/config-formats.md#settingsjsontemplate) + +### 3. .env.example + +**作用**:环境变量示例文件,用户复制为 `.env` 后填入真实值 + +**必需变量**: +```bash +# ai-proj MCP Server +AI_PROJ_API_BASE=https://ai.pipexerp.com/api/v1 +AI_PROJ_API_TOKEN=your_token_here + +# WPS MCP Server +WPS_APP_ID=your_wps_app_id +WPS_APP_KEY=your_wps_app_key + +# 飞书 MCP Server +FEISHU_APP_ID=your_feishu_app_id +FEISHU_APP_SECRET=your_feishu_secret + +# 工作区路径(可选,默认 ~/workspace) +WORKSPACE=~/coding/qiudl +``` + +**检查完整性**: +```bash +# .env.example 必须包含 settings.json.template 中的所有 ${VAR} +diff <(grep -oE '\${[A-Z_]+}' ~/.dotfiles/claude/settings.json.template | sort -u) \ + <(grep -oE '^[A-Z_]+=' ~/.dotfiles/claude/.env.example | sed 's/=$//' | sort) +``` + +--- + +## install.sh 工作流程 + +``` +1. 检查/安装 Homebrew + └─ 需要 sudo 密码(手动输入) + +2. 安装 Homebrew 软件 + └─ 从 Brewfile 读取软件列表 + +3. 创建符号链接 + ├─ ~/.zshrc -> ~/.dotfiles/.zshrc + ├─ ~/.gitconfig -> ~/.dotfiles/.gitconfig + └─ ~/.config/mise/config.toml -> ~/.dotfiles/mise/config.toml + +4. 配置 Claude Code 插件 + ├─ 添加私有市场源(claude-marketplace) + └─ 批量安装 plugins-list.yaml 中的插件 + +5. 渲染 MCP 配置 + ├─ 检查 envsubst 是否安装 + ├─ 加载 ~/.dotfiles/claude/.env + └─ 渲染 settings.json.template -> ~/.claude/settings.json + +6. 创建本地配置 + └─ ~/.zshrc.local (MY_BRANCH, OTHER_BRANCH) +``` + +--- + +## 验证部署结果 + +### 快速检查 + +```bash +# 1. 检查符号链接 +ls -la ~ | grep -E '\.zshrc|\.gitconfig' + +# 2. 检查 Homebrew +which brew +brew --version + +# 3. 检查 Claude Code +which claude +claude --version + +# 4. 检查 MCP 配置 +cat ~/.claude/settings.json | grep -E 'mcpServers|ai-proj' + +# 5. 检查插件 +claude /plugin list +``` + +### 完整验证 + +运行测试脚本(基于 REQ-20260220-0002 回归测试经验): + +```bash +~/.dotfiles/scripts/verify-deployment.sh +``` + +**测试内容**: +- TC-01: 私有市场源自动添加 +- TC-02: plugins-list.yaml 格式验证 +- TC-03: 插件批量安装验证 +- TC-04: settings.json.template 格式验证 +- TC-05: .env.example 完整性检查 +- TC-06: .env 在 .gitignore 中 +- TC-07: settings.json 模板渲染测试 +- TC-08: 完整 install.sh 执行测试 + +详见 [testing.md](docs/testing.md) + +--- + +## 常见问题 + +### 1. install.sh 执行时提示需要 sudo 密码 + +**原因**:Homebrew 安装需要管理员权限 + +**解决**: +- 本地执行:直接输入密码 +- 远程 SSH:无法自动化,需用户在本地终端执行 + +### 2. envsubst 命令未找到 + +**原因**:envsubst 由 gettext 包提供,install.sh 会自动安装 + +**解决**: +```bash +brew install gettext +``` + +### 3. 插件安装失败 + +**原因**: +- 市场源未添加 +- SSH 密钥未配置(私有仓库) +- 插件名称错误 + +**解决**: +```bash +# 1. 检查市场源 +ls ~/.claude/marketplaces/ + +# 2. 手动添加市场源 +claude /plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git + +# 3. 配置 SSH 密钥 +cat ~/.ssh/config # 检查 gitea.pipexerp.com 配置 +ssh -T git@gitea.pipexerp.com # 测试 SSH 连接 +``` + +### 4. settings.json 渲染后仍有 ${VAR} + +**原因**: +- .env 文件缺少对应变量 +- 变量名拼写错误 + +**解决**: +```bash +# 检查 .env 是否包含所有必需变量 +diff <(grep -oE '\${[A-Z_]+}' ~/.dotfiles/claude/settings.json.template | sed 's/[${}]//g' | sort -u) \ + <(grep -oE '^[A-Z_]+=' ~/.dotfiles/claude/.env | sed 's/=.*//' | sort) +``` + +更多问题见 [troubleshooting.md](docs/troubleshooting.md) + +--- + +## 最佳实践 + +### 1. 版本控制 + +**提交内容**: +- ✅ 配置模板(.env.example, settings.json.template) +- ✅ 安装脚本(install.sh) +- ✅ 插件清单(plugins-list.yaml) +- ❌ 敏感信息(.env, *.pem, *.key) + +**检查 .gitignore**: +```bash +# 确保以下条目存在 +grep -E '^\\.env$|^claude/\\.env$|^\\.pem$' ~/.dotfiles/.gitignore +``` + +### 2. 多机同步 + +**场景**:澳洲电脑 + 中国电脑 + 成都 Mac Mini + +**策略**: +```bash +# 1. 统一配置由 Git 同步 +git pull origin main + +# 2. 机器特定配置写入 ~/.zshrc.local +echo "MY_BRANCH=au-dev" >> ~/.zshrc.local +echo "OTHER_BRANCH=cn-dev" >> ~/.zshrc.local + +# 3. .zshrc 自动加载 .zshrc.local +[ -f ~/.zshrc.local ] && source ~/.zshrc.local +``` + +### 3. 测试驱动配置 + +**修改配置文件前**: +1. 在测试机上验证(如成都 Mac Mini) +2. 运行回归测试脚本 +3. 确认所有测试通过后再提交 + +**示例工作流**(基于 REQ-20260220-0002): +```bash +# 1. 修改配置 +vim ~/.dotfiles/claude/plugins-list.yaml + +# 2. 提交到 Git +git add claude/plugins-list.yaml +git commit -m "fix: 统一 plugins-list.yaml 格式" + +# 3. 推送到远程 +git push origin main + +# 4. 在测试机验证 +ssh chengdu "cd ~/.dotfiles && git pull && ./scripts/verify-deployment.sh" +``` + +--- + +## 相关技能 + +| 技能 | 用途 | +|------|------| +| `ops-tools` | 服务器运维工具 | +| `dev-test` | 软件测试方法论 | +| `req` | 需求工作流管理 | +| `skill-manager` | 技能自我进化管理 | + +--- + +## 经验教训(REQ-20260220-0002) + +### 问题1: plugins-list.yaml 格式不兼容 + +**现象**:install.sh 无法解析插件列表 + +**原因**:使用了分段格式(official_plugins/private_plugins),install.sh 只解析 `plugins:` 段落 + +**解决**:统一使用 `plugins:` 段落格式 + +**预防**: +```bash +# 格式验证脚本 +grep -E '^official_plugins:|^private_plugins:' ~/.dotfiles/claude/plugins-list.yaml && \ + echo "❌ 检测到旧格式" || echo "✅ 格式正确" +``` + +### 问题2: settings.json.template 占位符错误 + +**现象**:envsubst 渲染后仍有 {{VAR}} + +**原因**:使用了 {{VAR}} 格式,envsubst 只支持 ${VAR} + +**解决**:全局替换 `{{` -> `${`, `}}` -> `}` + +**预防**: +```bash +# 检查占位符格式 +grep '{{' ~/.dotfiles/claude/settings.json.template && \ + echo "❌ 使用了不兼容格式" || echo "✅ 格式正确" +``` + +### 问题3: .env.example 不完整 + +**现象**:渲染后 settings.json 包含未替换的 ${VAR} + +**原因**:.env.example 缺少某些变量定义 + +**解决**:确保 .env.example 包含 settings.json.template 中的所有变量 + +**预防**: +```bash +# 完整性检查脚本(见 scripts/check-env-completeness.sh) +~/.dotfiles/scripts/check-env-completeness.sh +``` + +### 问题4: .env 未加入 .gitignore + +**现象**:敏感 Token 被提交到 Git + +**原因**:.gitignore 缺少 .env 条目 + +**解决**:添加 `.env` 和 `claude/.env` 到 .gitignore + +**预防**: +```bash +# Git 提交前 hook 检查 +git diff --cached --name-only | grep -E '\\.env$' && \ + echo "⚠️ 警告:尝试提交 .env 文件" && exit 1 +``` + +--- + +## 部署检查清单 + +部署到新机器前,确认以下事项: + +- [ ] dotfiles 仓库已克隆到 ~/.dotfiles +- [ ] SSH 密钥已配置(Gitea 访问) +- [ ] 用户有 sudo 权限 +- [ ] 已复制 .env.example 为 .env 并填入真实 Token +- [ ] plugins-list.yaml 格式正确(统一 plugins: 段落) +- [ ] settings.json.template 使用 ${VAR} 格式 +- [ ] .env 在 .gitignore 中 +- [ ] 网络可访问 Homebrew、Gitea、MCP API + +部署后验证: + +- [ ] 运行 `~/.dotfiles/scripts/verify-deployment.sh` +- [ ] 检查所有测试用例通过 +- [ ] 手动验证 Claude Code 能连接 MCP Server +- [ ] 手动验证插件已正确安装 + +--- + +**更新时间**: 2026-02-20 +**维护者**: qiudl +**数据来源**: REQ-20260220-0002 成都机器回归测试 diff --git a/plugins/doubao-voice-plugin/.claude-plugin/plugin.json b/plugins/doubao-voice-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..6b90226 --- /dev/null +++ b/plugins/doubao-voice-plugin/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "doubao-voice-plugin", + "description": "Doubao (豆包) Voice API integration for TTS and ASR", + "version": "1.0.0", + "author": { + "name": "qiudl" + }, + "skills": [ + { + "name": "doubao-voice", + "path": "./skills/SKILL.md" + } + ] +} diff --git a/plugins/doubao-voice-plugin/.gitignore b/plugins/doubao-voice-plugin/.gitignore new file mode 100644 index 0000000..47eef11 --- /dev/null +++ b/plugins/doubao-voice-plugin/.gitignore @@ -0,0 +1,54 @@ +# 音频文件(生成的测试输出) +*.mp3 +*.wav +*.pcm + +# 测试脚本(仅本地使用) +scripts/test_*.py +scripts/check_credentials.py +scripts/README_TEST.md + +# 系统文件 +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# 环境配置(包含凭证的本地文件) +setup_env.local.sh +.env +.env.local + +# 测试生成的文件 +*.log +test_output/ diff --git a/plugins/doubao-voice-plugin/DEPLOY.md b/plugins/doubao-voice-plugin/DEPLOY.md new file mode 100644 index 0000000..5f4a7d1 --- /dev/null +++ b/plugins/doubao-voice-plugin/DEPLOY.md @@ -0,0 +1,201 @@ +# 部署指南 + +## 在另一台电脑上使用这个 Skill + +### ✅ 可以直接使用吗? + +**大部分功能可以直接使用!** 但需要做一些简单的配置。 + +--- + +## 📋 部署步骤 + +### 1️⃣ 将插件复制到新电脑 + +```bash +# 方式1: 从Git克隆 +git clone doubao-voice-plugin + +# 方式2: 复制文件夹 +cp -r doubao-voice-plugin /path/to/new/location +``` + +### 2️⃣ 安装依赖 + +**核心依赖** (必需): +```bash +pip3 install requests +``` + +**可选依赖** (仅用voice_converter_sdk.py时需要): +```bash +pip3 install volcengine +``` + +**检查是否安装成功**: +```bash +python3 -c "import requests; print('✅ requests 已安装')" +``` + +### 3️⃣ 配置凭证 + +创建本地配置文件: +```bash +cd scripts +cp setup_env.local.sh.example setup_env.local.sh +``` + +编辑 `setup_env.local.sh`,填入您的火山引擎凭证: +```bash +export DOUBAO_APP_ID="your_app_id" +export DOUBAO_ACCESS_TOKEN="your_access_token" +``` + +### 4️⃣ 使用 + +```bash +# 加载环境变量 +source scripts/setup_env.local.sh + +# 文字转语音 +python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3 + +# 语音转文字(需先启用ASR服务) +python3 scripts/voice_converter.py asr audio.mp3 +``` + +--- + +## 🔧 系统要求 + +| 需求 | 版本 | 状态 | +|------|------|------| +| **Python** | 3.6+ | ✅ 必需 | +| **requests** | 任意版本 | ✅ 必需 | +| **volcengine** | 任意版本 | ⚠️ 可选 | +| **操作系统** | Linux/Mac/Windows | ✅ 都支持 | + +--- + +## 🚨 常见问题 + +### Q: 错误 "ModuleNotFoundError: No module named 'requests'" +**解决**: +```bash +pip3 install requests +``` + +### Q: 错误 "DOUBAO_APP_ID not found" +**解决**: +```bash +# 检查环境变量 +echo $DOUBAO_APP_ID + +# 如果为空,重新加载配置 +source setup_env.local.sh +``` + +### Q: 为什么 ASR 不工作? +**原因**: 需要在火山引擎控制台启用 ASR 服务 +**解决**: 访问 https://console.volcengine.com/speech/service,启用语音识别服务 + +### Q: 可以在 Windows 上使用吗? +**可以!** 但环境变量设置方式不同: + +```batch +REM Windows CMD +set DOUBAO_APP_ID=your_app_id +set DOUBAO_ACCESS_TOKEN=your_access_token +python scripts\voice_converter.py tts "你好" -o hello.mp3 +``` + +或在 PowerShell: +```powershell +$env:DOUBAO_APP_ID="your_app_id" +$env:DOUBAO_ACCESS_TOKEN="your_access_token" +python scripts/voice_converter.py tts "你好" -o hello.mp3 +``` + +### Q: 如何在 Docker 中使用? +**Dockerfile 示例**: +```dockerfile +FROM python:3.9-slim + +WORKDIR /app +COPY . . + +RUN pip install requests + +ENV DOUBAO_APP_ID=${DOUBAO_APP_ID} +ENV DOUBAO_ACCESS_TOKEN=${DOUBAO_ACCESS_TOKEN} + +ENTRYPOINT ["python", "scripts/voice_converter.py"] +``` + +运行: +```bash +docker build -t doubao-voice . +docker run -e DOUBAO_APP_ID=xxx -e DOUBAO_ACCESS_TOKEN=xxx doubao-voice tts "你好" +``` + +--- + +## 📦 三种使用方式 + +### 方式 1: 命令行 (推荐简单使用) +```bash +python3 scripts/voice_converter.py tts "文本" -o output.mp3 +``` + +### 方式 2: Python 模块导入 +```python +import sys +sys.path.insert(0, 'scripts') +from voice_converter import DoubaoVoiceConverter + +converter = DoubaoVoiceConverter() +converter.text_to_speech("你好世界", output_file="hello.mp3") +``` + +### 方式 3: Claude Code Skill (自动) +如果安装在 Claude Code 的 plugins 目录,会自动识别为 Skill: +```bash +# 用户说: "把这段话转成语音:你好世界" +# → 自动调用 TTS API +``` + +--- + +## 🔐 安全提示 + +✅ **推荐做法**: +- 凭证存储在 `.local` 文件中(不在 Git 中) +- 使用环境变量而不是硬编码 +- 定期更新 Access Token + +❌ **不要做**: +- 不要把凭证提交到 Git +- 不要在脚本中硬编码凭证 +- 不要分享包含凭证的配置文件 + +--- + +## 📝 最小化部署清单 + +```bash +✅ 复制文件夹 +✅ pip install requests +✅ 复制并编辑 setup_env.local.sh +✅ source setup_env.local.sh +✅ python3 scripts/voice_converter.py tts "测试" +✅ 成功! +``` + +--- + +## 🆘 如需帮助 + +1. 检查 README.md (用户文档) +2. 查看 skills/SKILL.md (API 文档) +3. 查看 STATUS.md (开发状态) + diff --git a/plugins/doubao-voice-plugin/GIT_GUIDE.md b/plugins/doubao-voice-plugin/GIT_GUIDE.md new file mode 100644 index 0000000..bef01d0 --- /dev/null +++ b/plugins/doubao-voice-plugin/GIT_GUIDE.md @@ -0,0 +1,196 @@ +# Git 提交指南 + +## 📋 提交清单 + +### ✅ 应该提交的文件 + +```bash +git add . +git status # 确认以下文件已staged + +应包含: +- .claude-plugin/plugin.json # 插件配置 +- skills/SKILL.md # 技能文档 +- scripts/voice_converter.py # 核心工具 +- scripts/voice_converter_v2.py # 备选方案 +- scripts/voice_converter_sdk.py # 备选方案 +- scripts/check_credentials.py # 诊断工具 +- scripts/test_services.py # 服务测试 +- scripts/test_v3_debug.py # V3调试工具 +- scripts/setup_env.sh # 示例脚本(占位符版本) +- scripts/setup_env.local.sh.example # 本地配置模板 +- README.md # 用户文档 +- STATUS.md # 开发状态 +- .gitignore # Git忽略规则 +- GIT_GUIDE.md # 本文件 +``` + +### ❌ 被自动忽略的文件(勿手动提交) + +```bash +# .gitignore 已配置,以下文件不会被提交: +- *.mp3, *.wav, *.pcm # 音频文件 +- .DS_Store # 系统文件 +- setup_env.local.sh # 本地凭证文件 +- .env, .env.local # 环境变量文件 +- __pycache__/ # Python缓存 +- .vscode/, .idea/ # IDE配置 +``` + +--- + +## 🔐 凭证管理 (重要!) + +### 本地使用流程 + +```bash +# 1. 基于模板创建本地配置文件 +cd scripts +cp setup_env.local.sh.example setup_env.local.sh + +# 2. 编辑本地文件,填入您的真实凭证 +nano setup_env.local.sh # 或用您喜欢的编辑器 + +# 3. 本地使用时,source 本地文件 +source setup_env.local.sh + +# 4. 验证(注意:setup_env.local.sh 在 .gitignore 中) +git status # 应该看不到 setup_env.local.sh +``` + +### 关键安全要点 + +✅ **做这些**: +- 凭证存储在本地的 `.local` 文件中 +- 凭证存储在环境变量中(不硬编码) +- 公开文件只包含占位符 `your_app_id`, `your_access_token` +- 定期检查 git status 确保没有凭证被暴露 + +❌ **不要做这些**: +- 不要把真实凭证提交到 Git +- 不要硬编码凭证在 Python 文件中 +- 不要修改 .gitignore,让敏感文件被跟踪 +- 不要分享包含凭证的 shell 脚本 + +--- + +## 📝 提交步骤 + +```bash +# 1. 确保您创建了本地配置文件 +cd /Users/junhuang/coolbuy/claude-marketplace/plugins/doubao-voice-plugin/scripts +cp setup_env.local.sh.example setup_env.local.sh +# 编辑 setup_env.local.sh,填入您的凭证 + +# 2. 检查状态 +cd .. +git status + +# 3. 提交所有应提交的文件 +git add . + +# 4. 验证没有凭证泄露 +git diff --cached | grep -i "DOUBAO_APP_ID\|DOUBAO_ACCESS_TOKEN\|AKLT\|VOLCENGINE" +# 如果有输出,说明有凭证要被提交,请取消并修改 + +# 5. 提交 +git commit -m "feat: Add Doubao Voice plugin with TTS/ASR support" + +# 6. 再次检查 +git show HEAD # 确认提交内容 + +# 7. 推送 +git push origin main +``` + +--- + +## 🔍 验证清单 + +提交前,运行以下命令确认安全: + +```bash +# 检查是否有真实凭证在staged文件中 +git diff --cached | grep -E "2288996168|LlDjcX-_UEnn4OW87iMorpXccQUilaHX|AKLTMGQ3" +# 正常情况下应该没有输出 + +# 检查 setup_env.local.sh 是否被忽略 +git status | grep setup_env.local.sh +# 应该看不到这个文件 + +# 检查 .gitignore 配置是否正确 +cat .gitignore | grep "setup_env.local" +# 应该看到这一行 + +# 查看即将提交的文件列表 +git ls-files +# 确认关键文件都在其中,但不包含 setup_env.local.sh +``` + +--- + +## 使用说明(给其他用户) + +在您发布插件后,其他用户应该: + +```bash +# 1. 克隆插件 +git clone doubao-voice-plugin +cd doubao-voice-plugin/scripts + +# 2. 创建本地配置 +cp setup_env.local.sh.example setup_env.local.sh + +# 3. 编辑配置,填入他们自己的凭证 +vim setup_env.local.sh + +# 4. 配置环境变量 +source setup_env.local.sh + +# 5. 测试功能 +python3 voice_converter.py tts "测试" + +# 6. setup_env.local.sh 不会被版本控制跟踪 +git status # 看不到 setup_env.local.sh ✅ +``` + +--- + +## FAQ + +**Q: 我不小心提交了凭证怎么办?** + +A: 立即执行: +```bash +# 从 Git 历史中移除敏感文件 +git rm --cached scripts/setup_env.local.sh +git commit --amend -m "Remove sensitive file" + +# 更改您的火山引擎 Access Token(出于安全考虑) +# 在控制台重新生成新的 token +``` + +**Q: 为什么需要 setup_env.local.sh.example?** + +A: 这样其他用户可以看到配置文件应该包含哪些环境变量,而不会暴露任何真实凭证。 + +**Q: 可以把凭证放在 ~/.bashrc 里吗?** + +A: 可以,但 setup_env.local.sh 更加灵活,易于项目专用配置。 + +**Q: 如何在 CI/CD 中使用敏感凭证?** + +A: 在 CI/CD 平台(GitHub Actions, GitLab CI等)中使用 Secrets/Variables 功能,不要在代码中硬编码。 + +--- + +## 总结 + +✅ **已完成的安全措施**: +1. ✓ .gitignore 配置了敏感文件忽略规则 +2. ✓ setup_env.sh 改为占位符版本 +3. ✓ 创建了 setup_env.local.sh.example 模板 +4. ✓ 所有代码文件使用环境变量读取凭证 +5. ✓ 提供了清晰的本地配置说明 + +现在可以安全地提交到 Git!🎉 diff --git a/plugins/doubao-voice-plugin/README.md b/plugins/doubao-voice-plugin/README.md new file mode 100644 index 0000000..c2af159 --- /dev/null +++ b/plugins/doubao-voice-plugin/README.md @@ -0,0 +1,182 @@ +# 豆包语音插件 (Doubao Voice Plugin) + +火山引擎豆包语音API集成插件,支持文字转语音(TTS)和唱歌功能。 + +## 功能特性 + +- **✅ 语音合成 (TTS)**: 文字转语音,支持多种音色 - **已测试可用** +- **🎵 唱歌**: 让豆包唱歌,支持实时语音交互 - **已开通端到端大模型** +- **简单易用**: 命令行工具,一行命令即可使用 +- **多种音色**: 支持女声/男声等多种基础音色 +- **实时交互**: 支持与豆包进行实时对话和唱歌 + +## 快速开始 + +### 1. 获取API凭证 + +访问 [火山引擎控制台](https://console.volcengine.com/speech/app) 创建应用并获取: +- **App ID** (数字) +- **Access Token** (长字符串) + +开通所需服务: +1. 在控制台勾选 **"语音合成"** 服务 (TTS) + +### 2. 配置环境变量 + +**方式1: 使用配置脚本 (推荐)** +```bash +cd scripts +source setup_env.sh # 自动设置环境变量 +``` + +**方式2: 手动设置** +```bash +export DOUBAO_APP_ID="your_app_id" +export DOUBAO_ACCESS_TOKEN="your_access_token" +``` + +### 3. 安装依赖 + +```bash +pip3 install requests --break-system-packages +``` + +### 4. 检查凭证 + +```bash +# 检查凭证配置 +python3 scripts/check_credentials.py +``` + +### 5. 使用示例 + +#### TTS 文字转语音(命令行) + +```bash +cd scripts + +# 基础用法 - ✅ 已测试可用 +python3 voice_converter.py tts "你好,我是豆包语音助手" -o output.mp3 + +# 使用不同音色 +python3 voice_converter.py tts "测试男声" -o male.mp3 -v BV701_V2_streaming +``` + +#### 唱歌(命令行)🎵 + +```bash +cd scripts + +# 让豆包唱歌 +python3 singing.py sing "请唱一首关于春天的歌" -o spring.mp3 + +# 交互式唱歌模式(实时对话) +python3 singing.py interactive +``` + +#### Python 代码方式 + +```python +# TTS - 文字转语音 +from scripts.voice_converter import DoubaoVoiceConverter + +converter = DoubaoVoiceConverter() +audio_file = converter.text_to_speech("你好,欢迎使用豆包", output_file="hello.mp3") + +# 唱歌 +import asyncio +from scripts.singing import DoubaoSinging + +async def sing(): + singing = DoubaoSinging() + audio_file = await singing.sing("请唱一首情歌", output_file="love_song.mp3") + +asyncio.run(sing()) +``` + +## 自然语言调用 + +在 Claude Code 中可以使用自然语言调用: + +**TTS 文字转语音**: +- "把这段话转成语音:你好世界" +- "用温柔女声合成语音" +- "用男声朗读这段文字" + +**唱歌**: +- "请唱一首关于春天的歌" +- "唱一个温柔的摇篮曲" +- "开启与豆包的实时语音对话模式" + +示例: +``` +用户: "帮我把'欢迎使用豆包语音'转成语音" +Claude: 调用TTS服务生成output.mp3 +``` + +## 价格说明 + +### TTS (语音合成) +- 大模型并发版: 2000元/并发/月 +- 按量付费: 按字符数计费 + +### 免费试用 +新用户开通服务后可获得免费额度。 + +## 支持的音色 + +| 音色代码 | 描述 | 场景 | 状态 | +|---------|------|------|------| +| BV700_V2_streaming | 通用女声 | 通用场景 | ✅ V1 可用 | +| BV701_V2_streaming | 通用男声 | 通用场景 | ✅ V1 可用 | +| BV406_streaming | 温柔女声 | 客服、助手 | ✅ V1 可用 | +| BV158_streaming | 活泼女声 | 教育、娱乐 | ✅ V1 可用 | +| BV115_streaming | 磁性男声 | 新闻、播音 | ✅ V1 可用 | + +**注意**: 豆包2.0高级音色需要使用V3 API,目前正在调试中。 + +## 常见问题 + +### TTS 返回 "requested resource not granted" +**解决方法**: 在控制台勾选"语音合成"服务选项 + +### Authorization 头格式错误 +确保使用 `Bearer;{token}` 格式(注意分号),而不是 `Bearer {token}` + +### 环境变量未生效 +```bash +# 检查环境变量 +echo $DOUBAO_APP_ID +echo $DOUBAO_ACCESS_TOKEN + +# 如果为空,重新设置 +source setup_env.sh +``` + +## API 版本说明 + +### V1 API (当前使用) ✅ +- **状态**: 已测试,稳定可用 +- **认证**: Bearer Token +- **音色**: 支持基础音色 +- **推荐**: 日常使用推荐 + +### V3 API (豆包2.0) ⚠️ +- **状态**: 调试中,存在 "get resource id empty" 问题 +- **认证**: Bearer Token + Resource-Id +- **音色**: 支持豆包2.0高级音色 +- **说明**: 需要联系火山引擎技术支持获取正确配置 + +## 技术支持 + +- [官方文档](https://www.volcengine.com/docs/6561/1359369) +- [控制台](https://console.volcengine.com/speech/app) +- [计费说明](https://www.volcengine.com/docs/6561/1359370) + +## 许可证 + +本插件遵循 MIT 许可证。 + +## 作者 + +qiudl @ zhiyuncai.com diff --git a/plugins/doubao-voice-plugin/STATUS.md b/plugins/doubao-voice-plugin/STATUS.md new file mode 100644 index 0000000..7283217 --- /dev/null +++ b/plugins/doubao-voice-plugin/STATUS.md @@ -0,0 +1,200 @@ +# 豆包语音插件 - 开发状态 + +**更新时间**: 2026-02-07 +**版本**: 1.0.0 + +--- + +## ✅ 已完成功能 + +### 1. TTS (文字转语音) - 完全可用 ✅ + +**测试状态**: 通过 +**API版本**: V1 +**可用音色**: +- BV700_V2_streaming (通用女声) +- BV701_V2_streaming (通用男声) +- BV406_streaming (温柔女声) +- BV158_streaming (活泼女声) +- BV115_streaming (磁性男声) + +**测试命令**: +```bash +source scripts/setup_env.sh +python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3 +``` + +**测试结果**: +- ✅ HTTP 200 OK +- ✅ Code 3000 Success +- ✅ 成功生成 MP3 文件 +- ✅ 音质正常 + +--- + +## ⚠️ 待完成功能 + +### 2. ASR (语音转文字) - 待启用服务 + +**问题**: Code 1001 - "requested resource not granted" + +**原因**: ASR 服务未在火山引擎控制台正确启用 + +**解决步骤**: +1. 访问: https://console.volcengine.com/speech/service +2. 找到 "语音识别 (ASR)" 服务 +3. 确保服务已启用并勾选必要选项 +4. 等待服务生效(可能需要几分钟) +5. 重新测试 + +**测试命令** (服务启用后): +```bash +python3 scripts/voice_converter.py asr audio.mp3 +``` + +--- + +### 3. V3 API / 豆包2.0音色 - 调试中 + +**问题**: Code 45000000 - "get resource id empty" + +**已尝试的方法**: +- [x] Resource-Id header +- [x] X-Resource-Id header +- [x] resource_id query parameter +- [x] resource_id in app config +- [x] 多种 resource_id 值: volc.bigmodel.tts, volc.seed-tts.default, volc.tts.default + +**当前状态**: 所有方法均返回相同错误 + +**可能原因**: +1. V3 API 可能需要不同的认证方式 (IAM签名) +2. 需要特殊的服务实例配置 +3. Resource-Id 的获取或配置方法不正确 + +**建议**: +- 联系火山引擎技术支持获取 V3 API 正确配置方法 +- 或继续使用 V1 API (已满足基本需求) + +--- + +## 📁 项目文件结构 + +``` +plugins/doubao-voice-plugin/ +├── .claude-plugin/ +│ └── plugin.json # 插件元数据 +├── skills/ +│ └── SKILL.md # 技能定义和文档 +├── scripts/ +│ ├── voice_converter.py # 主转换工具 (V1 API, 可用) +│ ├── voice_converter_v2.py # 手动签名版本 (待测试) +│ ├── voice_converter_sdk.py # SDK版本 (待测试) +│ ├── check_credentials.py # 凭证检查工具 +│ ├── test_services.py # 服务状态测试 +│ ├── test_v3_debug.py # V3 API 调试脚本 +│ ├── setup_env.sh # 环境变量配置脚本 +│ └── README_TEST.md # 测试报告 +├── README.md # 用户文档 +└── STATUS.md # 本文件 (开发状态) +``` + +--- + +## 🔧 诊断工具 + +### 检查凭证配置 +```bash +python3 scripts/check_credentials.py +``` +显示当前环境变量配置状态 + +### 测试服务状态 +```bash +python3 scripts/test_services.py +``` +测试 TTS 和 ASR 服务是否可用 + +### V3 API 调试 +```bash +python3 scripts/test_v3_debug.py +``` +测试多种 V3 API 配置方式 + +--- + +## 📊 当前凭证配置 + +```bash +DOUBAO_APP_ID="your_app_id" +DOUBAO_ACCESS_TOKEN="your_access_token" + +# V3 可选配置 (暂不可用) +# DOUBAO_USE_V3="true" +# DOUBAO_RESOURCE_ID="volc.bigmodel.tts" +``` + +**Access Key 信息** (用于签名认证,暂未使用): +- Access Key ID: your_access_key_id +- Secret Access Key: your_secret_access_key + +--- + +## 🎯 下一步计划 + +### 立即可用 +1. ✅ **使用 TTS 功能** + - 集成到应用中 + - 测试不同音色 + - 生产环境部署 + +### 短期目标 (1-3天) +2. ⚠️ **启用 ASR 服务** + - 在控制台启用服务 + - 测试语音识别功能 + - 完善错误处理 + +### 长期目标 (可选) +3. 🔄 **V3 API 支持** + - 联系火山引擎技术支持 + - 获取正确的 Resource-Id 配置方法 + - 支持豆包2.0高级音色 + +--- + +## 📞 技术支持 + +### 火山引擎 +- 文档: https://www.volcengine.com/docs/6561/1329505 +- 控制台: https://console.volcengine.com/speech/app +- 服务管理: https://console.volcengine.com/speech/service + +### 常见问题解决 +1. **TTS 可用但 ASR 不可用** + - 检查控制台 ASR 服务是否启用 + - 确认勾选了"语音识别"选项 + +2. **V3 API 持续报错** + - 暂时使用 V1 API + - 联系火山引擎技术支持 + +3. **认证失败** + - 检查环境变量是否正确设置 + - 确认 Access Token 格式正确 + - 注意 Authorization header 使用 `Bearer;{token}` (有分号) + +--- + +## ✨ 总结 + +**当前可用**: TTS (文字转语音) 功能完全可用,可以投入使用 + +**待解决**: +1. 在控制台启用 ASR 服务 +2. (可选) 解决 V3 API 配置问题 + +**建议**: 先使用 V1 API 的 TTS 功能,满足基本语音合成需求。ASR 功能在控制台启用服务后即可使用。V3 API 的豆包2.0音色为可选功能,可以后续再解决。 + +--- + +*Generated by Claude Code on 2026-02-07* diff --git a/plugins/doubao-voice-plugin/scripts/README.md b/plugins/doubao-voice-plugin/scripts/README.md new file mode 100644 index 0000000..694fba4 --- /dev/null +++ b/plugins/doubao-voice-plugin/scripts/README.md @@ -0,0 +1,186 @@ +# 豆包语音工具使用指南 + +简单易用的豆包语音命令行工具,支持**文字转语音(TTS)**和**唱歌**。 + +## 快速开始 + +### 1. 配置环境变量 + +```bash +# 在 ~/.zshrc 或 ~/.bashrc 中添加 +export DOUBAO_APP_ID="your_app_id" +export DOUBAO_ACCESS_TOKEN="your_access_token" + +# 使配置生效 +source ~/.zshrc +``` + +### 2. 安装依赖 + +```bash +pip install requests +``` + +## 使用方法 + +### 📝 文字转语音 (TTS) + +**基础用法:** +```bash +python voice_converter.py tts "你好,我是豆包语音助手" +``` + +**指定输出文件和音色:** +```bash +python voice_converter.py tts "欢迎使用豆包语音" -o welcome.mp3 -v BV701_V2_streaming +``` + +**可用音色:** +- `BV700_V2_streaming` - 通用女声(默认,推荐) +- `BV701_V2_streaming` - 通用男声 +- `BV406_streaming` - 温柔女声 +- `BV158_streaming` - 活泼女声 +- `BV115_streaming` - 磁性男声 + +### 🎵 唱歌 (Singing) + +**基础用法:** +```bash +python singing.py sing "请唱一首关于春天的歌" +``` + +**指定输出文件:** +```bash +python singing.py sing "唱一个温柔的摇篮曲" -o lullaby.mp3 +``` + +**交互式模式(实时对话):** +```bash +python singing.py interactive +``` + +在交互模式下可以自然地与豆包对话,要求她唱歌、讲故事等。输入 `quit` 退出。 + +## Python 代码调用 + +```python +# TTS - 文字转语音 +from voice_converter import DoubaoVoiceConverter + +converter = DoubaoVoiceConverter() +audio_file = converter.text_to_speech( + "你好,欢迎使用豆包语音", + output_file="hello.mp3", + voice_type="BV700_V2_streaming" +) +print(f"生成语音: {audio_file}") + +# 唱歌 +import asyncio +from singing import DoubaoSinging + +async def main(): + singing = DoubaoSinging() + + # 让豆包唱歌 + audio_file = await singing.sing( + "请唱一首情歌", + output_file="love_song.mp3", + language="zh-CN" + ) + print(f"唱歌完成: {audio_file}") + + # 或启动交互模式 + # await singing.interactive_singing() + +asyncio.run(main()) +``` + +## 完整示例 + +### 示例1:生成通知语音 + +```bash +# 生成女声通知 +python voice_converter.py tts "您有一条新消息,请注意查收" -o notification.mp3 + +# 生成男声通知 +python voice_converter.py tts "系统将在5分钟后进行维护" -o maintenance.mp3 -v BV701_V2_streaming +``` + +### 示例2:唱歌 + +```bash +# 让豆包唱一首情歌 +python singing.py sing "请唱一首温柔的情歌" -o love_song.mp3 + +# 让豆包唱一首儿歌 +python singing.py sing "唱一首欢快的儿歌" -o kids_song.mp3 + +# 启动交互式模式与豆包对话 +python singing.py interactive +``` + + +## 错误处理 + +### 常见错误 + +**1. 环境变量未设置** +``` +❌ 错误: 请先设置环境变量: +export DOUBAO_APP_ID='your_app_id' +export DOUBAO_ACCESS_TOKEN='your_access_token' +``` +**解决:** 确保已正确设置环境变量并 `source ~/.zshrc` + +**2. API 调用失败** +``` +❌ 错误: TTS 失败 (code: 4001): Invalid token +``` +**解决:** 检查 Access Token 是否正确或已过期 + +## 技术参数 + +### 音频格式要求 + +**TTS 输出:** +- 格式:MP3 +- 采样率:16000 Hz +- 声道:单声道 + +### API 限制 + +- **TTS**: 单次最长 5000 字符 +- **并发限制**: 根据购买的并发数 + +## 在 Claude Code 中使用 + +在 Claude Code 中可以直接用自然语言调用: + +**TTS - 文字转语音**: +``` +"把这段话转成语音:你好世界" +"用温柔女声合成:欢迎光临" +``` + +**唱歌**: +``` +"请唱一首关于春天的歌" +"唱一个温柔的摇篮曲" +"开启与豆包的实时语音对话模式" +``` + +## 获取 API 凭证 + +1. 访问 [火山引擎控制台](https://console.volcengine.com/speech/app) +2. 创建应用 +3. 获取 App ID 和 Access Token +4. 开通所需服务: + - 豆包语音合成模型2.0 + +## 参考链接 + +- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561) +- [API 接口文档](https://www.volcengine.com/docs/6561/1096680) +- [计费说明](https://www.volcengine.com/docs/6561/1359370) diff --git a/plugins/doubao-voice-plugin/scripts/setup_env.local.sh.example b/plugins/doubao-voice-plugin/scripts/setup_env.local.sh.example new file mode 100644 index 0000000..8a59801 --- /dev/null +++ b/plugins/doubao-voice-plugin/scripts/setup_env.local.sh.example @@ -0,0 +1,21 @@ +#!/bin/bash +# 豆包语音 API 环境变量配置(本地版本) +# +# 使用说明: +# 1. 复制本文件: cp setup_env.local.sh.example setup_env.local.sh +# 2. 编辑 setup_env.local.sh,填入您的真实凭证 +# 3. 运行: source setup_env.local.sh +# 4. .gitignore 已配置忽略 setup_env.local.sh,所以您的凭证不会被提交到 Git + +# ⚠️ 重要:请在下面填入您的真实凭证(仅本地使用) +export DOUBAO_APP_ID="your_app_id_here" +export DOUBAO_ACCESS_TOKEN="your_access_token_here" + +# V3 API 配置 (可选,如需豆包2.0音色) +# export DOUBAO_USE_V3="true" +# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts" + +echo "✅ 豆包语音 API 环境变量已设置(本地配置)" +echo "" +echo "App ID: ${DOUBAO_APP_ID:0:10}..." +echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..." diff --git a/plugins/doubao-voice-plugin/scripts/setup_env.sh b/plugins/doubao-voice-plugin/scripts/setup_env.sh new file mode 100755 index 0000000..8de2cf7 --- /dev/null +++ b/plugins/doubao-voice-plugin/scripts/setup_env.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# 豆包语音 API 环境变量配置 (示例) +# +# ⚠️ 重要:这是示例脚本,包含占位符。 +# 本地使用时,请参考 setup_env.local.sh.example 创建 setup_env.local.sh, +# 然后在其中填入您的真实凭证。.gitignore 已配置忽略 .local 文件。 + +export DOUBAO_APP_ID="your_app_id" +export DOUBAO_ACCESS_TOKEN="your_access_token" + +# V3 API 配置 (可选,如需豆包2.0音色) +# export DOUBAO_USE_V3="true" +# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts" + +echo "✅ 豆包语音 API 环境变量已设置" +echo "" +echo "App ID: $DOUBAO_APP_ID" +echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..." +echo "" +echo "现在可以运行:" +echo " python3 voice_converter.py tts \"你好世界\" -o hello.mp3" +echo " python3 voice_converter.py asr audio.mp3 # 需先启用ASR服务" diff --git a/plugins/doubao-voice-plugin/scripts/singing.py b/plugins/doubao-voice-plugin/scripts/singing.py new file mode 100755 index 0000000..897fd4f --- /dev/null +++ b/plugins/doubao-voice-plugin/scripts/singing.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +豆包唱歌工具 +基于豆包端到端实时语音大模型,支持让豆包唱歌 +使用WebSocket实时对话和生成音频 +""" + +import os +import sys +import json +import asyncio +import websockets +import struct +import uuid +from typing import Optional + + +# 连接级事件(不需要session_id) +CONNECTION_EVENTS = {1, 2, 50, 51, 52} + + +class DoubaoSinging: + """豆包唱歌工具类""" + + def __init__(self): + # 从环境变量读取配置 + self.app_id = os.environ.get("DOUBAO_APP_ID") + self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN") + + if not self.app_id or not self.access_token: + raise ValueError( + "请先设置环境变量:\n" + "export DOUBAO_APP_ID='your_app_id'\n" + "export DOUBAO_ACCESS_TOKEN='your_access_token'" + ) + + # 端到端实时语音WebSocket地址 + self.ws_url = "wss://openspeech.bytedance.com/api/v3/realtime/dialogue" + self.app_key = "PlgvMymc7f3tQnJ6" # 固定值 + self.resource_id = "volc.speech.dialog" # 固定值 + + def _build_message(self, event_id: int, payload: dict = None, session_id: str = None) -> bytes: + """ + 构建二进制消息 + + 协议格式: + - header (4 bytes) + - event_id (4 bytes, big-endian) + - [session_id_len (4 bytes) + session_id (variable)] -- 仅非连接级事件 + - payload_len (4 bytes, big-endian) + - payload (variable, JSON) + """ + buf = bytearray() + + # Header (4 bytes) + buf.append(0x11) # version=1, header_size=1 + buf.append(0x14) # FULL_CLIENT_REQUEST(0x1) + WITH_EVENT(0x4) + buf.append(0x10) # JSON serialization, no compression + buf.append(0x00) # reserved + + # Event ID + buf.extend(struct.pack('>I', event_id)) + + # Session ID (required for non-connection events) + if event_id not in CONNECTION_EVENTS: + sid_bytes = (session_id or "").encode('utf-8') + buf.extend(struct.pack('>I', len(sid_bytes))) + buf.extend(sid_bytes) + + # Payload + if payload: + payload_bytes = json.dumps(payload, ensure_ascii=False).encode('utf-8') + else: + payload_bytes = b'{}' + buf.extend(struct.pack('>I', len(payload_bytes))) + buf.extend(payload_bytes) + + return bytes(buf) + + def _parse_response(self, data: bytes) -> dict: + """ + 解析服务端二进制消息 + + Returns: + dict with keys: msg_type, event_id, session_id, payload, payload_bytes + """ + result = {"raw": data} + if len(data) < 4: + return result + + # Header + msg_type = (data[1] >> 4) & 0x0F + flags = data[1] & 0x0F + result["msg_type"] = msg_type + + offset = 4 + + # Event ID (if WITH_EVENT flag) + if flags & 0x04 and len(data) >= offset + 4: + event_id = struct.unpack('>I', data[offset:offset + 4])[0] + result["event_id"] = event_id + offset += 4 + + # Connect ID for connection events (50, 51, 52) + if event_id in {50, 51, 52} and len(data) >= offset + 4: + cid_len = struct.unpack('>I', data[offset:offset + 4])[0] + offset += 4 + if len(data) >= offset + cid_len: + result["connect_id"] = data[offset:offset + cid_len].decode('utf-8', errors='ignore') + offset += cid_len + # Session ID for session-level events + elif event_id not in CONNECTION_EVENTS and len(data) >= offset + 4: + sid_len = struct.unpack('>I', data[offset:offset + 4])[0] + offset += 4 + if len(data) >= offset + sid_len: + result["session_id"] = data[offset:offset + sid_len].decode('utf-8', errors='ignore') + offset += sid_len + + # Payload + if len(data) >= offset + 4: + payload_len = struct.unpack('>I', data[offset:offset + 4])[0] + offset += 4 + if len(data) >= offset + payload_len: + payload_raw = data[offset:offset + payload_len] + result["payload_bytes"] = payload_raw + # Audio-only responses (msg_type 0xB) have raw audio + if msg_type == 0x0B: + result["is_audio"] = True + else: + try: + result["payload"] = json.loads(payload_raw.decode('utf-8')) + except: + result["payload_text"] = payload_raw.decode('utf-8', errors='ignore') + + return result + + async def sing( + self, + song_request: str, + output_file: str = "singing_output.mp3", + language: str = "zh-CN", + model: str = "1.2.1.0" + ) -> str: + """ + 让豆包唱歌 + + Args: + song_request: 唱歌请求,如 "请唱一首关于春天的歌" + output_file: 输出音频文件路径 + language: 语言代码 (zh-CN/en-US) + model: 模型版本 + + Returns: + str: 输出文件路径 + """ + print(f"🎵 豆包唱歌中...") + print(f" 请求: {song_request}") + print(f" 模型: {model}") + + try: + audio_data = bytearray() + session_id = str(uuid.uuid4()) + + # WebSocket连接头 + headers = { + "X-Api-App-ID": self.app_id, + "X-Api-Access-Key": self.access_token, + "X-Api-Resource-Id": self.resource_id, + "X-Api-App-Key": self.app_key, + "X-Api-Connect-Id": str(uuid.uuid4()), + } + + async with websockets.connect(self.ws_url, additional_headers=headers) as websocket: + print("✅ WebSocket连接成功") + + # 1. StartConnection (event_id=1, 无需session_id) + await websocket.send(self._build_message(1)) + response = await asyncio.wait_for(websocket.recv(), timeout=5) + resp = self._parse_response(response) + if resp.get("event_id") == 50: + print(f"✅ 连接已建立") + else: + print(f"⚠️ 连接响应: {resp}") + + # 2. StartSession (event_id=100, 需要session_id) + start_session_payload = { + "tts": { + "audio_config": { + "channel": 1, + "format": "pcm", + "sample_rate": 24000 + } + }, + "dialog": { + "extra": { + "enable_music": True, + "input_mod": "text", + "model": model + } + } + } + await websocket.send(self._build_message(100, start_session_payload, session_id)) + response = await asyncio.wait_for(websocket.recv(), timeout=5) + resp = self._parse_response(response) + if resp.get("event_id") == 150: + print(f"✅ 会话已建立") + elif resp.get("payload", {}).get("error"): + print(f"❌ 会话错误: {resp['payload']['error']}") + return None + else: + print(f"📋 会话响应: {resp}") + + # 3. SayHello/ChatTextQuery (event_id=300, 需要session_id) + chat_payload = {"content": song_request} + await websocket.send(self._build_message(300, chat_payload, session_id)) + print(f"📤 已发送唱歌请求") + + # 4. 接收音频流(使用超时检测结束) + print("\n📋 接收音频流...") + tts_started = False + recv_timeout = 5 # 5秒无数据则认为结束 + + while True: + try: + message = await asyncio.wait_for(websocket.recv(), timeout=recv_timeout) + except asyncio.TimeoutError: + break + except websockets.exceptions.ConnectionClosed: + break + + if isinstance(message, bytes) and len(message) >= 4: + resp = self._parse_response(message) + msg_type = resp.get("msg_type", 0) + flags = message[1] & 0x0F + + # Audio-only response (0xB = 11) + if resp.get("is_audio") and resp.get("payload_bytes"): + audio_data.extend(resp["payload_bytes"]) + if not tts_started: + print(f" 接收音频中...", end="", flush=True) + tts_started = True + else: + print(".", end="", flush=True) + + # NEG_SEQUENCE flag = last packet + if flags & 0x02: + break + + # Server error (0xF = 15) + elif msg_type == 0x0F: + error = resp.get("payload", {}).get("error", "unknown") + print(f"\n❌ 服务器错误: {error}") + break + + # Full server response (0x9) - session finished + elif msg_type == 0x09: + event_id = resp.get("event_id", 0) + if event_id in {152, 52}: + break + + # 5. 保存音频文件 + if audio_data: + # Save as PCM, convert extension if needed + actual_output = output_file + if output_file.endswith('.mp3'): + actual_output = output_file.replace('.mp3', '.pcm') + + with open(actual_output, "wb") as f: + f.write(audio_data) + + file_size = len(audio_data) / 1024 + print(f"\n\n✅ 唱歌完成!") + print(f" 输出: {actual_output} ({file_size:.1f} KB)") + print(f" 格式: PCM (24000Hz, 单声道)") + return actual_output + else: + print("\n⚠️ 未收到音频数据,请检查:") + print(" 1. 凭证是否正确") + print(" 2. 端到端实时语音大模型是否已开通") + print(" 3. 网络连接是否正常") + return None + + except websockets.exceptions.WebSocketException as e: + raise Exception(f"WebSocket连接错误: {str(e)}") + except Exception as e: + raise Exception(f"唱歌调用失败: {str(e)}") + + +def main(): + """命令行工具""" + import argparse + + parser = argparse.ArgumentParser(description="豆包唱歌工具") + subparsers = parser.add_subparsers(dest="command", help="选择功能") + + # 唱歌命令 + sing_parser = subparsers.add_parser("sing", help="让豆包唱歌") + sing_parser.add_argument("request", help="唱歌请求,如 '请唱一首关于春天的歌'") + sing_parser.add_argument( + "-o", "--output", default="singing_output.mp3", help="输出音频文件(默认: singing_output.mp3)" + ) + sing_parser.add_argument( + "-l", "--language", default="zh-CN", help="语言代码(默认: zh-CN)" + ) + sing_parser.add_argument( + "-m", "--model", default="1.2.1.0", help="模型版本(默认: 1.2.1.0=O2.0版本)" + ) + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + singing = DoubaoSinging() + + if args.command == "sing": + asyncio.run(singing.sing(args.request, args.output, args.language, args.model)) + + except Exception as e: + print(f"❌ 错误: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/doubao-voice-plugin/scripts/voice_converter.py b/plugins/doubao-voice-plugin/scripts/voice_converter.py new file mode 100755 index 0000000..0d0073b --- /dev/null +++ b/plugins/doubao-voice-plugin/scripts/voice_converter.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +豆包语音转换工具 +支持:文字转语音 (TTS) +""" + +import os +import sys +import json +import base64 +import requests +from pathlib import Path + + +class DoubaoVoiceConverter: + """豆包语音转换工具类""" + + def __init__(self): + # 从环境变量读取配置 + self.app_id = os.environ.get("DOUBAO_APP_ID") + self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN") + + if not self.app_id or not self.access_token: + raise ValueError( + "请先设置环境变量:\n" + "export DOUBAO_APP_ID='your_app_id'\n" + "export DOUBAO_ACCESS_TOKEN='your_access_token'" + ) + + # API版本选择: V1 (默认, 支持基础音色) 或 V3 (豆包2.0, 需额外配置) + self.use_v3 = os.environ.get("DOUBAO_USE_V3", "false").lower() == "true" + + if self.use_v3: + self.tts_url = "https://openspeech.bytedance.com/api/v3/tts/unidirectional" + self.resource_id = os.environ.get("DOUBAO_RESOURCE_ID", "volc.bigmodel.tts") + else: + # V1 API - 稳定可用,支持基础音色 + self.tts_url = "https://openspeech.bytedance.com/api/v1/tts" + + def text_to_speech( + self, + text: str, + output_file: str = "output.mp3", + voice_type: str = "BV700_V2_streaming" + ) -> str: + """ + 文字转语音 (TTS) + + Args: + text: 要转换的文字 + output_file: 输出音频文件路径 + voice_type: 音色类型 + - BV700_V2_streaming: 通用女声(推荐) + - BV701_V2_streaming: 通用男声 + - BV406_streaming: 温柔女声 + - BV158_streaming: 活泼女声 + - BV115_streaming: 磁性男声 + + Returns: + str: 输出文件路径 + """ + print(f"📝 文字转语音中...") + print(f" 文字: {text[:50]}{'...' if len(text) > 50 else ''}") + print(f" 音色: {voice_type}") + + headers = { + "Authorization": f"Bearer;{self.access_token}", + "Content-Type": "application/json" + } + + # V3 API需要Resource-Id (如果启用) + if self.use_v3: + headers["Resource-Id"] = self.resource_id + + payload = { + "app": { + "appid": self.app_id, + "token": self.access_token, + "cluster": "volcano_tts" + }, + "user": { + "uid": "user_001" + }, + "audio": { + "voice_type": voice_type, + "encoding": "mp3", + "speed_ratio": 1.0, + "volume_ratio": 1.0, + "pitch_ratio": 1.0 + }, + "request": { + "reqid": f"tts_{os.urandom(8).hex()}", + "text": text, + "text_type": "plain", + "operation": "query" + } + } + + try: + response = requests.post(self.tts_url, headers=headers, json=payload, timeout=30) + + # 打印响应头信息 + print(f"\n📋 响应信息:") + print(f" HTTP状态码: {response.status_code}") + if 'X-Tt-Logid' in response.headers: + print(f" RequestId: {response.headers['X-Tt-Logid']}") + if 'X-Request-Id' in response.headers: + print(f" X-Request-Id: {response.headers['X-Request-Id']}") + + data = response.json() + + # 打印完整响应 + print(f"\n📄 完整响应:") + print(json.dumps(data, indent=2, ensure_ascii=False)) + print() + + if data.get("code") == 3000: + # 成功:解码并保存音频 + audio_data = base64.b64decode(data["data"]) + with open(output_file, "wb") as f: + f.write(audio_data) + + file_size = len(audio_data) / 1024 # KB + print(f"✅ 语音合成成功!") + print(f" 输出: {output_file} ({file_size:.1f} KB)") + return output_file + else: + error_msg = data.get("message", "未知错误") + reqid = data.get("reqid", "未知") + raise Exception(f"TTS 失败\n 错误码: {data.get('code')}\n 错误信息: {error_msg}\n RequestId: {reqid}") + + except requests.exceptions.Timeout: + raise Exception("请求超时,请检查网络连接") + except Exception as e: + raise Exception(f"TTS 调用失败: {str(e)}") + + + +def main(): + """命令行工具""" + import argparse + + parser = argparse.ArgumentParser(description="豆包语音转换工具") + subparsers = parser.add_subparsers(dest="command", help="选择功能") + + # TTS 命令 + tts_parser = subparsers.add_parser("tts", help="文字转语音") + tts_parser.add_argument("text", help="要转换的文字") + tts_parser.add_argument("-o", "--output", default="output.mp3", help="输出音频文件(默认: output.mp3)") + tts_parser.add_argument("-v", "--voice", default="BV700_V2_streaming", + help="音色类型(默认: BV700_V2_streaming 通用女声)") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + converter = DoubaoVoiceConverter() + + if args.command == "tts": + converter.text_to_speech(args.text, args.output, args.voice) + + except Exception as e: + print(f"❌ 错误: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/doubao-voice-plugin/skills/SKILL.md b/plugins/doubao-voice-plugin/skills/SKILL.md new file mode 100644 index 0000000..7ca9a1a --- /dev/null +++ b/plugins/doubao-voice-plugin/skills/SKILL.md @@ -0,0 +1,508 @@ +--- +name: doubao-voice +description: 豆包语音API调用。支持语音合成(TTS)和唱歌。当用户提到语音合成、文字转语音、唱歌、豆包语音相关任务时自动激活。 +--- + +# 豆包语音API技能 + +调用火山引擎豆包语音API,实现文字转语音(TTS)和唱歌功能。 + +## 核心功能 ⭐ + +### 1. 文字转语音 (TTS) + +```bash +# 1. 配置环境变量 +export DOUBAO_APP_ID="your_app_id" +export DOUBAO_ACCESS_TOKEN="your_access_token" + +# 2. 文字转语音 +python scripts/voice_converter.py tts "你好世界" +``` + +### 2. 唱歌 🎵 + +```bash +# 让豆包唱歌 +python scripts/singing.py sing "请唱一首关于春天的歌" + +# 交互式唱歌模式 +python scripts/singing.py interactive +``` + +## 功能概述 + +| 模块 | 功能 | 推荐模型 | +|------|------|---------| +| **语音合成 (TTS)** | 文字转语音、多种音色 | 豆包语音合成模型2.0 | +| **唱歌** | 实时语音交互、唱歌、角色扮演 | 豆包端到端实时语音大模型 | + +--- + +## 环境配置 + +### 1. 获取火山引擎豆包语音凭证 + +1. 访问 [火山引擎控制台](https://console.volcengine.com/) +2. 开通「豆包语音」服务 +3. 创建应用获取 `App ID` 和 `Access Token` +4. 开通所需服务: + - 「语音合成」权限:大模型语音合成 + +### 2. 环境变量配置 + +```bash +# ~/.zshrc 或 ~/.bashrc +export DOUBAO_APP_ID="your_app_id" +export DOUBAO_ACCESS_TOKEN="your_access_token" +export DOUBAO_CLUSTER="volcano_tts" # TTS服务集群 +``` + +### 3. Python 依赖 + +```bash +# 推荐使用 uv +uv pip install requests websocket-client + +# 或使用 pip +pip install requests websocket-client +``` + +--- + +## API 基础 + +### Base URL + +``` +TTS API: https://openspeech.bytedance.com/api/v1/tts +``` + +### 认证方式 + +使用 Access Token 进行认证,在请求头中添加: +``` +Authorization: Bearer {access_token} +``` + +--- + +## 一、语音合成 (TTS) + +### 1.1 基础语音合成 + +将文本转换为语音文件。 + +**自然语言示例**: +- "把这段文字转成语音" +- "用豆包合成语音" +- "生成语音:你好,欢迎使用豆包语音" + +**Python 实现**: + +```python +import os +import requests +import json +import base64 + +def text_to_speech(text: str, voice_type: str = "BV700_V2_streaming", output_file: str = "output.mp3"): + """ + 文字转语音 + + Args: + text: 要合成的文本 + voice_type: 音色类型 (默认: BV700_V2_streaming) + output_file: 输出音频文件路径 + + Returns: + 音频文件路径 + """ + app_id = os.environ.get("DOUBAO_APP_ID") + access_token = os.environ.get("DOUBAO_ACCESS_TOKEN") + cluster = os.environ.get("DOUBAO_CLUSTER", "volcano_tts") + + url = "https://openspeech.bytedance.com/api/v1/tts" + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + payload = { + "app": { + "appid": app_id, + "token": access_token, + "cluster": cluster + }, + "user": { + "uid": "user123" + }, + "audio": { + "voice_type": voice_type, + "encoding": "mp3", + "speed_ratio": 1.0, + "volume_ratio": 1.0, + "pitch_ratio": 1.0 + }, + "request": { + "reqid": "req_" + os.urandom(8).hex(), + "text": text, + "text_type": "plain", + "operation": "query" + } + } + + response = requests.post(url, headers=headers, json=payload) + data = response.json() + + if data.get("code") == 3000: + # 解码音频数据 + audio_data = base64.b64decode(data["data"]) + with open(output_file, "wb") as f: + f.write(audio_data) + return output_file + else: + raise Exception(f"TTS 失败: {data}") + +# 使用示例 +audio_file = text_to_speech("你好,我是豆包语音助手") +print(f"语音已生成: {audio_file}") +``` + +### 1.2 流式语音合成 + +适用于长文本,边生成边播放。 + +```python +import websocket +import json +import os + +def stream_tts(text: str, voice_type: str = "BV700_V2_streaming"): + """ + 流式语音合成 + + Args: + text: 要合成的文本 + voice_type: 音色类型 + """ + app_id = os.environ.get("DOUBAO_APP_ID") + access_token = os.environ.get("DOUBAO_ACCESS_TOKEN") + + ws_url = f"wss://openspeech.bytedance.com/api/v1/tts/ws?appid={app_id}&token={access_token}" + + def on_message(ws, message): + data = json.loads(message) + if "audio" in data: + # 处理音频数据 + audio_chunk = base64.b64decode(data["audio"]) + # 播放或保存音频片段 + print(f"收到音频片段: {len(audio_chunk)} 字节") + + def on_open(ws): + payload = { + "app": { + "appid": app_id, + "token": access_token, + "cluster": "volcano_tts" + }, + "user": { + "uid": "user123" + }, + "audio": { + "voice_type": voice_type, + "encoding": "mp3" + }, + "request": { + "reqid": "stream_" + os.urandom(8).hex(), + "text": text, + "text_type": "plain", + "operation": "submit" + } + } + ws.send(json.dumps(payload)) + + ws = websocket.WebSocketApp( + ws_url, + on_message=on_message, + on_open=on_open + ) + ws.run_forever() + +# 使用示例 +stream_tts("这是一段很长的文本,使用流式合成可以边生成边播放...") +``` + +### 1.3 音色选择 + +豆包语音提供多种音色: + +| 音色代码 | 描述 | 场景 | +|---------|------|------| +| BV700_V2_streaming | 通用女声 | 通用场景 | +| BV701_V2_streaming | 通用男声 | 通用场景 | +| BV406_streaming | 温柔女声 | 客服、助手 | +| BV158_streaming | 活泼女声 | 教育、娱乐 | +| BV115_streaming | 磁性男声 | 新闻、播音 | + +**查询可用音色**: + +```bash +TOKEN="${DOUBAO_ACCESS_TOKEN}" +APP_ID="${DOUBAO_APP_ID}" + +curl -s "https://openspeech.bytedance.com/api/v1/tts/voices?appid=$APP_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + +## 完整工具类 + +```python +import os +import requests +import base64 +import json +from typing import Optional + +class DoubaoVoice: + """豆包语音API工具类""" + + BASE_URL = "https://openspeech.bytedance.com/api/v1" + + def __init__(self, app_id: str = None, access_token: str = None): + self.app_id = app_id or os.environ.get("DOUBAO_APP_ID") + self.access_token = access_token or os.environ.get("DOUBAO_ACCESS_TOKEN") + self.cluster_tts = os.environ.get("DOUBAO_CLUSTER", "volcano_tts") + + @property + def headers(self): + return { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json" + } + + def text_to_speech( + self, + text: str, + voice_type: str = "BV700_V2_streaming", + output_file: str = "output.mp3" + ) -> str: + """文字转语音""" + url = f"{self.BASE_URL}/tts" + + payload = { + "app": { + "appid": self.app_id, + "token": self.access_token, + "cluster": self.cluster_tts + }, + "user": {"uid": "user123"}, + "audio": { + "voice_type": voice_type, + "encoding": "mp3", + "speed_ratio": 1.0, + "volume_ratio": 1.0, + "pitch_ratio": 1.0 + }, + "request": { + "reqid": "req_" + os.urandom(8).hex(), + "text": text, + "text_type": "plain", + "operation": "query" + } + } + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") == 3000: + audio_data = base64.b64decode(data["data"]) + with open(output_file, "wb") as f: + f.write(audio_data) + return output_file + else: + raise Exception(f"TTS 失败: {data}") + + def list_voices(self) -> list: + """获取可用音色列表""" + url = f"{self.BASE_URL}/tts/voices" + params = {"appid": self.app_id} + + response = requests.get(url, headers=self.headers, params=params) + data = response.json() + + if data.get("code") == 0: + return data["voices"] + else: + raise Exception(f"获取音色列表失败: {data}") + + +# ==================== 使用示例 ==================== +if __name__ == "__main__": + voice = DoubaoVoice() + + # 示例1: 文字转语音 + audio_file = voice.text_to_speech("你好,我是豆包语音助手") + print(f"语音已生成: {audio_file}") + + # 示例2: 查看可用音色 + voices = voice.list_voices() + for v in voices[:5]: + print(f"{v['voice_type']}: {v['description']}") +``` + +--- + +## 二、唱歌 (豆包端到端实时语音大模型) + +### 2.1 基础唱歌 + +让豆包唱歌,支持任何歌曲主题。 + +**自然语言示例**: +- "请唱一首关于春天的歌" +- "唱一个温柔的摇篮曲" +- "来一首欢快的儿歌" + +**Python 实现**: + +```python +import asyncio +from scripts.singing import DoubaoSinging + +async def main(): + singing = DoubaoSinging() + + # 让豆包唱歌 + audio_file = await singing.sing( + "请唱一首关于春天的歌", + output_file="spring_song.mp3", + language="zh-CN" + ) + print(f"唱歌完成: {audio_file}") + +asyncio.run(main()) +``` + +### 2.2 交互式唱歌 + +与豆包进行实时对话,可以要求她唱歌、讲故事等。 + +**Python 实现**: + +```python +import asyncio +from scripts.singing import DoubaoSinging + +async def main(): + singing = DoubaoSinging() + + # 启动交互式模式 + await singing.interactive_singing(language="zh-CN") + +asyncio.run(main()) +``` + +**交互示例**: +``` +你: 请唱一首情歌 +豆包: [生成音频] 我会为你唱一首温柔的情歌... + +你: 能加点方言吗? +豆包: [用方言重新唱歌] + +你: quit +再见! +``` + +--- + +## 自然语言操作示例 + +### TTS 操作 + +| 用户说 | 执行操作 | +|--------|----------| +| "把这段话转成语音:你好世界" | 调用 TTS API 生成语音 | +| "用温柔女声合成语音" | 使用 BV406_streaming 音色 | +| "生成一段播音腔的新闻语音" | 使用磁性男声音色 | + +### 唱歌操作 + +| 用户说 | 执行操作 | +|--------|----------| +| "请唱一首关于春天的歌" | 调用端到端实时语音大模型生成唱歌音频 | +| "唱一首摇篮曲" | 生成温柔的摇篮曲 | +| "唱歌的同时讲个故事" | 交互式对话中唱歌并讲故事 | +| "开启交互式唱歌模式" | 启动实时语音交互 | + +--- + +## 计费说明 + +### TTS 计费 + +- **并发版**: 2000元/并发/月(纯并发计费,不收取字符调用费用) +- **按量付费**: 按合成字符数计费 + +### 免费试用 + +新用户开通服务后可获得一定免费额度,具体额度以控制台显示为准。 + +--- + +## 注意事项 + +1. **音频格式**: TTS 支持 mp3/wav/pcm +2. **文本长度**: TTS 单次请求最长支持 5000 字符 +3. **并发限制**: 注意 API 调用频率和并发数限制 +4. **Token 安全**: Access Token 存储在环境变量中,不要硬编码 + +--- + +## 错误处理 + +```python +def safe_tts(text: str): + """带错误处理的 TTS""" + try: + voice = DoubaoVoice() + return voice.text_to_speech(text) + except Exception as e: + if "401" in str(e): + print("认证失败,请检查 Access Token") + elif "429" in str(e): + print("请求过于频繁,请稍后重试") + else: + print(f"合成失败: {e}") + return None +``` + +--- + +## 常见场景 + +### 场景 1: 生成多语言语音 + +```python +voice = DoubaoVoice() + +# 中文 +voice.text_to_speech("你好", voice_type="BV700_V2_streaming", output_file="zh.mp3") + +# 英文 +voice.text_to_speech("Hello", voice_type="EN_001", output_file="en.mp3") +``` + + +--- + +## 参考资源 + +- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561/1359369) +- [豆包语音控制台](https://console.volcengine.com/speech/app) +- [API 接口文档](https://www.volcengine.com/docs/6561/1359370) +- [计费说明](https://www.volcengine.com/docs/6561/1359370) diff --git a/plugins/enjoysa-deploy-plugin/.claude-plugin/plugin.json b/plugins/enjoysa-deploy-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..4b33c21 --- /dev/null +++ b/plugins/enjoysa-deploy-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "enjoysa-deploy-plugin", + "description": "EnjoySA 项目部署到新加坡服务器", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/enjoysa-deploy-plugin/skills/SKILL.md b/plugins/enjoysa-deploy-plugin/skills/SKILL.md new file mode 100644 index 0000000..4d0501e --- /dev/null +++ b/plugins/enjoysa-deploy-plugin/skills/SKILL.md @@ -0,0 +1,113 @@ +# EnjoySA 部署技能 + +## 触发条件 + +当用户提到以下内容时自动激活: +- enjoysa 部署 +- 部署到新加坡 +- deploy enjoysa + +--- + +## 项目信息 + +| 项目 | 值 | +|------|-----| +| 本地路径 | `/Users/donglinlai/coding/qiudl/enjoysa` | +| Git 仓库 | `https://gitea.pipexerp.com/qiudl/enjoysa.git` | +| 主分支 | main | + +--- + +## 服务器信息 + +| 项目 | 值 | +|------|-----| +| 服务器 | singapore (43.134.28.147) | +| 用户 | ubuntu | +| SSH 密钥 | ~/.ssh/singpore.pem | +| 部署路径 | /opt/enjoysa | +| 访问地址 | http://43.134.28.147:6066 | +| Web 服务 | Nginx (端口 6066) | + +--- + +## 快速部署 + +执行部署脚本: +```bash +./scripts/deploy.sh +``` + +--- + +## 手动部署步骤 + +### 1. 构建 +```bash +cd web && npm run build +``` + +### 2. 上传 +```bash +scp -r web/dist/* singapore:/opt/enjoysa/ +``` + +### 3. 验证 +```bash +ssh singapore "curl -s -o /dev/null -w '%{http_code}' http://localhost:6066/" +``` + +--- + +## 常用运维命令 + +```bash +# 查看 Nginx 状态 +ssh singapore "sudo systemctl status nginx" + +# 重载 Nginx 配置 +ssh singapore "sudo nginx -t && sudo systemctl reload nginx" + +# 查看部署目录 +ssh singapore "ls -la /opt/enjoysa/" + +# 查看 Nginx 访问日志 +ssh singapore "sudo tail -f /var/log/nginx/access.log" + +# 查看错误日志 +ssh singapore "sudo tail -f /var/log/nginx/error.log" +``` + +--- + +## Nginx 配置 + +配置文件: `/etc/nginx/sites-available/enjoysa` + +```nginx +server { + listen 6066; + listen [::]:6066; + server_name _; + root /opt/enjoysa; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} +``` + +--- + +## 注意事项 + +1. 腾讯云安全组需开放 6066 端口 +2. SPA 应用使用 `try_files` 支持前端路由 +3. 静态资源设置长期缓存 diff --git a/plugins/enjoysa-plugin/.claude-plugin/plugin.json b/plugins/enjoysa-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..a05480c --- /dev/null +++ b/plugins/enjoysa-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "enjoysa-plugin", + "description": "Plugin for enjoysa", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/enjoysa-plugin/skills/SKILL.md b/plugins/enjoysa-plugin/skills/SKILL.md new file mode 100644 index 0000000..a2a7fcf --- /dev/null +++ b/plugins/enjoysa-plugin/skills/SKILL.md @@ -0,0 +1,210 @@ +--- +name: enjoysa +description: EnjoySA 畅游南澳旅游平台开发。用于前端页面开发、组件开发、i18n国际化。当用户提到 enjoysa、畅游南澳、旅游平台、供应商后台相关任务时自动激活。 +--- + +# EnjoySA 开发技能 + +畅游南澳旅游平台,面向 C 端游客和 B 端供应商的在线旅游服务系统。 + +## 项目信息 + +| 项目 | 值 | +|------|-----| +| 本地路径 | `/Users/donglinlai/coding/qiudl/enjoysa` | +| Git 仓库 | `https://gitea.pipexerp.com/qiudl/enjoysa.git` | +| 主分支 | main | +| 技术栈 | Vite + React 18 + TypeScript + CSS Modules | +| 部署服务器 | singapore (43.134.28.147:6066) | +| 访问地址 | http://43.134.28.147:6066 | + +--- + +## 架构概览 + +``` +enjoysa/ +├── web/ # React 前端 +│ ├── src/ +│ │ ├── components/ # 通用组件 +│ │ │ ├── Common/ # Header, Footer, LanguageSwitcher +│ │ │ ├── Auth/ # LoginForm, RegisterForm +│ │ │ └── Supplier/ # SupplierLoginForm, SupplierRegisterForm +│ │ ├── pages/ # 页面组件 +│ │ │ ├── LoginPage/ # C端用户登录 +│ │ │ ├── SupplierLoginPage/# B端供应商登录 +│ │ │ ├── AdminLoginPage/ # 平台管理员登录 +│ │ │ ├── TermsPage/ # 服务条款 +│ │ │ ├── PrivacyPage/ # 隐私政策 +│ │ │ └── Supplier/ # 供应商后台模块 +│ │ ├── i18n/ # 国际化 +│ │ │ └── locales/ # 翻译文件 +│ │ │ ├── zh-CN.json +│ │ │ └── en-US.json +│ │ └── types/ # 类型定义 +│ ├── scripts/ # 部署脚本 +│ └── dist/ # 构建产物 +├── backend/ # Go 后端 (规划中) +└── docs/ # 文档 +``` + +--- + +## 页面路由 + +### C 端 (游客) + +| 路由 | 页面 | 说明 | +|------|------|------| +| `/` | HomePage | 首页 | +| `/login` | LoginPage | 用户登录/注册 | +| `/terms` | TermsPage | 服务条款 | +| `/privacy` | PrivacyPage | 隐私政策 | + +### B 端 (供应商) + +| 路由 | 页面 | 说明 | +|------|------|------| +| `/supplier/login` | SupplierLoginPage | 供应商登录 | +| `/supplier/register` | SupplierRegisterPage | 供应商入驻申请 | +| `/supplier` | SupplierDashboard | 供应商仪表盘 | +| `/supplier/products` | SupplierProducts | 产品管理 | +| `/supplier/orders` | SupplierOrders | 订单处理 | +| `/supplier/finance` | SupplierFinance | 财务结算 | + +### 管理端 + +| 路由 | 页面 | 说明 | +|------|------|------| +| `/admin/login` | AdminLoginPage | 管理员登录 | + +--- + +## i18n 国际化 + +### 翻译文件位置 + +``` +web/src/i18n/locales/ +├── zh-CN.json # 简体中文 +└── en-US.json # 英文 +``` + +### 命名空间规范 + +| 命名空间 | 用途 | 示例 | +|---------|------|------| +| `brand.*` | 品牌信息 | brand.name, brand.tagline | +| `auth.*` | 登录注册 | auth.loginTitle, auth.emailPlaceholder | +| `terms.*` | 服务条款 | terms.title, terms.sections.* | +| `privacy.*` | 隐私政策 | privacy.title, privacy.sections.* | +| `supplier.*` | 供应商模块 | supplier.login.*, supplier.dashboard.* | +| `admin.*` | 管理员模块 | admin.login.*, admin.dashboard.* | +| `common.*` | 通用文本 | common.submit, common.cancel | +| `footer.*` | 页脚 | footer.supplierEntry, footer.copyright | + +### 添加新翻译步骤 + +1. 在 `zh-CN.json` 和 `en-US.json` 中同时添加新 key +2. 使用 `useTranslation()` hook 获取 `t` 函数 +3. 使用 `t('namespace.key')` 获取翻译文本 + +```tsx +import { useTranslation } from 'react-i18next'; + +const MyComponent = () => { + const { t } = useTranslation(); + return

{t('brand.name')}

; +}; +``` + +### 注意事项 + +- **禁止硬编码中文/英文**,所有用户可见文本必须使用 i18n +- 长文本内容(如条款、政策)使用 `\n` 分段 +- JSON 中避免使用中文引号 `""` `''`,使用 `「」` 或英文引号 + +--- + +## 组件规范 + +### Common 组件 + +| 组件 | 用途 | 文件 | +|------|------|------| +| Header | 顶部导航栏 | `components/Common/Header/` | +| Footer | 页脚(含供应商入口) | `components/Common/Footer/` | +| LanguageSwitcher | 语言切换器 | `components/Common/LanguageSwitcher/` | + +### Auth 组件 + +| 组件 | 用途 | 文件 | +|------|------|------| +| LoginForm | C端登录表单 | `components/Auth/LoginForm/` | +| RegisterForm | C端注册表单 | `components/Auth/RegisterForm/` | + +### Supplier 组件 + +| 组件 | 用途 | 文件 | +|------|------|------| +| SupplierLoginForm | 供应商登录表单 | `components/Supplier/SupplierLoginForm/` | +| SupplierRegisterForm | 供应商入驻申请表单 | `components/Supplier/SupplierRegisterForm/` | + +--- + +## 本地开发 + +### 启动前端 + +```bash +cd /Users/donglinlai/coding/qiudl/enjoysa/web +npm install +npm run dev +``` + +### 构建 + +```bash +npm run build +``` + +### 部署 + +```bash +./scripts/deploy.sh +``` + +或手动部署: +```bash +scp -r web/dist/* singapore:/opt/enjoysa/ +``` + +--- + +## 供应商类型 + +系统支持以下供应商类型: + +| 类型 | 说明 | +|------|------| +| travel_agency | 旅行社 | +| hotel | 酒店 | +| attraction | 景区 | +| restaurant | 餐厅 | +| car_rental | 租车公司 | + +--- + +## 相关技能 + +- `enjoysa-deploy` - 部署到新加坡服务器 +- `frontend-design` - 前端界面设计 +- `dev-coding` - 软件编码开发 + +--- + +## 版本历史 + +| 版本 | 日期 | 变更 | +|------|------|------| +| 1.0.0 | 2026-01-31 | 初始版本,包含项目结构、页面路由、i18n规范 | diff --git a/plugins/executing-plans-plugin/.claude-plugin/plugin.json b/plugins/executing-plans-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..7667f96 --- /dev/null +++ b/plugins/executing-plans-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "executing-plans-plugin", + "description": "Plugin for executing-plans", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/executing-plans-plugin/skills/SKILL.md b/plugins/executing-plans-plugin/skills/SKILL.md new file mode 100644 index 0000000..3dc267e --- /dev/null +++ b/plugins/executing-plans-plugin/skills/SKILL.md @@ -0,0 +1,96 @@ +--- +name: executing-plans +description: Use when you have a written implementation plan to execute in a separate session with review checkpoints +--- + +# Executing Plans + +## Overview + +Load plan, review critically, create branch, execute tasks in batches, report for review between batches. + +**Core principle:** Batch execution with checkpoints for architect review. + +**Announce at start:** "I'm using the executing-plans skill to implement this plan." + +## The Process + +### Step 1: Load and Review Plan +1. Read plan file +2. Review critically - identify any questions or concerns about the plan +3. If concerns: Raise them with your human partner before starting +4. If no concerns: Proceed to branch setup + +### Step 2: Setup Branch +**Before any implementation, ensure proper branch isolation.** + +1. Check if already on a feature branch for this task +2. If not, use `/pr start` to create one: + ```bash + /pr start + # Example: /pr start feature REQ-123 user-login + ``` +3. If no REQ-id available, ask user or create branch manually: + ```bash + git fetch origin + git checkout -b / origin/main + ``` +4. Confirm branch is ready before proceeding + +**Branch types:** `feature`, `fix`, `refactor` + +### Step 3: Create Tasks and Execute Batch +**Default: First 3 tasks** + +1. Create TodoWrite tasks from plan +2. For each task in batch: + - Mark as in_progress + - Follow each step exactly (plan has bite-sized steps) + - Run verifications as specified + - Mark as completed + +### Step 4: Report +When batch complete: +- Show what was implemented +- Show verification output +- Say: "Ready for feedback." + +### Step 5: Continue +Based on feedback: +- Apply changes if needed +- Execute next batch +- Repeat until complete + +### Step 6: Complete Development + +After all tasks complete and verified: +- Announce: "I'm using the finishing-a-development-branch skill to complete this work." +- **REQUIRED SUB-SKILL:** Use superpowers:finishing-a-development-branch +- Follow that skill to verify tests, present options, execute choice + +## When to Stop and Ask for Help + +**STOP executing immediately when:** +- Hit a blocker mid-batch (missing dependency, test fails, instruction unclear) +- Plan has critical gaps preventing starting +- You don't understand an instruction +- Verification fails repeatedly + +**Ask for clarification rather than guessing.** + +## When to Revisit Earlier Steps + +**Return to Review (Step 1) when:** +- Partner updates the plan based on your feedback +- Fundamental approach needs rethinking + +**Don't force through blockers** - stop and ask. + +## Remember +- Review plan critically first +- **Create feature branch before implementation** +- Follow plan steps exactly +- Don't skip verifications +- Reference skills when plan says to +- Between batches: just report and wait +- Stop when blocked, don't guess diff --git a/plugins/feishu-bitable-plugin/.claude-plugin/plugin.json b/plugins/feishu-bitable-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..ee9dc71 --- /dev/null +++ b/plugins/feishu-bitable-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "feishu-bitable-plugin", + "description": "飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/feishu-bitable-plugin/skills/SKILL.md b/plugins/feishu-bitable-plugin/skills/SKILL.md new file mode 100644 index 0000000..b3c23c6 --- /dev/null +++ b/plugins/feishu-bitable-plugin/skills/SKILL.md @@ -0,0 +1,130 @@ +--- +name: feishu-bitable +description: 飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。 +--- + +# 飞书多维表格 (Bitable) + +## URL 结构 + +``` +https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX + └── app_token └── table_id +``` + +## 核心操作 + +### 列出记录 + +```python +def list_records(app_token, table_id, filter_str=None, page_size=100): + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + params = {"page_size": page_size} + if filter_str: + params["filter"] = filter_str + response = requests.get(url, headers=headers, params=params) + return response.json()["data"]["items"] +``` + +### 创建记录 + +```python +def create_record(app_token, table_id, fields): + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + response = requests.post(url, headers=headers, json={"fields": fields}) + return response.json()["data"]["record"] + +# 示例 +create_record("BascXXX", "tblXXX", {"任务名称": "完成开发", "状态": "进行中"}) +``` + +### 批量创建 (最多 500 条) + +```python +url = f".../records/batch_create" +requests.post(url, headers=headers, json={"records": [{"fields": {...}}, ...]}) +``` + +### 更新记录 + +```python +def update_record(app_token, table_id, record_id, fields): + url = f".../records/{record_id}" + response = requests.put(url, headers=headers, json={"fields": fields}) + return response.json()["data"]["record"] +``` + +### 删除记录 + +```python +def delete_record(app_token, table_id, record_id): + url = f".../records/{record_id}" + return requests.delete(url, headers=headers).json() +``` + +## 筛选条件 + +```python +# 等于 +'CurrentValue.[状态]="进行中"' + +# 包含 +'CurrentValue.[标题].contains("任务")' + +# 大于(数字/日期) +'CurrentValue.[优先级]>2' + +# 组合 +'AND(CurrentValue.[状态]="进行中", CurrentValue.[优先级]>2)' +'OR(CurrentValue.[状态]="完成", CurrentValue.[状态]="归档")' + +# 空值 +'CurrentValue.[截止日期]=BLANK()' +``` + +## 排序 + +```python +params = { + "sort": '[{"field_name":"优先级","desc":true}]' +} +``` + +## 字段类型 + +| 类型 | 值格式 | +|------|--------| +| 文本 | `"字符串"` | +| 数字 | `123` | +| 单选 | `"选项值"` | +| 多选 | `["选项1", "选项2"]` | +| 日期 | `1706400000000` (毫秒时间戳) | +| 人员 | `[{"id": "ou_xxx"}]` | +| 复选框 | `true/false` | +| 链接 | `{"link": "https://...", "text": "显示文本"}` | + +## 完整工具类 + +见 `~/.claude/skills/feishu/feishu_bitable.py` + +```python +from feishu_bitable import FeishuBitable + +bitable = FeishuBitable() +records = bitable.list_records("BascXXX", "tblXXX") +bitable.create_record("BascXXX", "tblXXX", {"名称": "测试"}) +bitable.batch_create("BascXXX", "tblXXX", [{"名称": "1"}, {"名称": "2"}]) +``` + +## 常见场景 + +```python +# 获取待处理任务 +tasks = bitable.list_records("BascXXX", "tblXXX", + filter_str='CurrentValue.[状态]="待处理"') + +# 批量更新状态 +for task in tasks: + bitable.update_record("BascXXX", "tblXXX", + task["record_id"], {"状态": "已完成"}) +``` diff --git a/plugins/feishu-docx-plugin/.claude-plugin/plugin.json b/plugins/feishu-docx-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..8abd968 --- /dev/null +++ b/plugins/feishu-docx-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "feishu-docx-plugin", + "description": "飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/feishu-docx-plugin/skills/SKILL.md b/plugins/feishu-docx-plugin/skills/SKILL.md new file mode 100644 index 0000000..1858e05 --- /dev/null +++ b/plugins/feishu-docx-plugin/skills/SKILL.md @@ -0,0 +1,126 @@ +--- +name: feishu-docx +description: 飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。 +--- + +# 飞书云文档 (Docx) + +## URL 结构 + +``` +https://xxx.feishu.cn/docx/DoxcXXXXXX + └── document_id +``` + +## 创建文档 + +```python +def create_document(title, folder_token=None): + url = "https://open.feishu.cn/open-apis/docx/v1/documents" + payload = {"title": title} + if folder_token: + payload["folder_token"] = folder_token + response = requests.post(url, headers=headers, json=payload) + doc = response.json()["data"]["document"] + return {"document_id": doc["document_id"], "url": f"https://feishu.cn/docx/{doc['document_id']}"} +``` + +## 设置权限 + +```python +def set_permission(document_id, editable=True): + """设置文档为组织内可编辑/只读""" + url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{document_id}/public" + payload = { + "external_access_entity": "open", + "link_share_entity": "tenant_editable" if editable else "tenant_readable" + } + requests.patch(url, headers=headers, params={"type": "docx"}, json=payload) +``` + +## 内容块类型 + +| 类型 | block_type | 示例 | +|------|------------|------| +| 段落 | 2 | 普通文本 | +| 一级标题 | 3 | # 标题 | +| 二级标题 | 4 | ## 标题 | +| 三级标题 | 5 | ### 标题 | +| 无序列表 | 13 | - 列表项 | +| 有序列表 | 14 | 1. 列表项 | +| 代码块 | 16 | ```code``` | +| 引用 | 18 | > 引用 | +| 分割线 | 22 | --- | +| 图片 | 27 | 需先上传 | + +## 创建内容块 + +```python +def create_block(document_id, block_id, block_type, content): + url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children" + + if block_type in [3, 4, 5]: # 标题 + block = {"block_type": block_type, "heading": {"elements": [{"text_run": {"content": content}}]}} + elif block_type == 2: # 段落 + block = {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}} + elif block_type in [13, 14]: # 列表 + block = {"block_type": block_type, "bullet/ordered": {"elements": [{"text_run": {"content": content}}]}} + + requests.post(url, headers=headers, json={"children": [block], "index": -1}) +``` + +## 图片上传 + +```python +# 1. 上传图片到素材库 +def upload_image(file_path, parent_node): + url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all" + with open(file_path, 'rb') as f: + files = {'file': f} + data = {'file_name': os.path.basename(file_path), 'parent_type': 'docx_image', 'parent_node': parent_node} + response = requests.post(url, headers={"Authorization": f"Bearer {token}"}, files=files, data=data) + return response.json()["data"]["file_token"] + +# 2. 插入图片块 +def insert_image(document_id, block_id, file_token): + block = {"block_type": 27, "image": {"token": file_token}} + # ... 同 create_block +``` + +## 会议纪要模板 + +```python +def create_meeting_notes(title, date, attendees, agenda, decisions, action_items): + doc = create_document(f"{title} - {date}") + doc_id = doc["document_id"] + + # 获取根块 + root = requests.get(f".../documents/{doc_id}/blocks/{doc_id}").json() + root_id = root["data"]["block"]["block_id"] + + # 添加内容 + create_block(doc_id, root_id, 3, f"会议纪要:{title}") + create_block(doc_id, root_id, 2, f"日期:{date}") + create_block(doc_id, root_id, 2, f"参会人:{', '.join(attendees)}") + create_block(doc_id, root_id, 4, "议程") + for item in agenda: + create_block(doc_id, root_id, 13, item) + create_block(doc_id, root_id, 4, "决议") + for item in decisions: + create_block(doc_id, root_id, 13, item) + create_block(doc_id, root_id, 4, "待办事项") + for item in action_items: + create_block(doc_id, root_id, 14, item) + + return doc +``` + +## 完整工具类 + +见 `~/.claude/skills/feishu/feishu_docx.py` + +## 注意事项 + +- 创建文档必须指定 `folder_token`,否则会出现在「与我共享」 +- 默认存储到 `C80gfkRnzlonQ5d4AhOcOACDnNg`(01运营文件夹) +- 图片必须先上传到素材库,再插入文档 diff --git a/plugins/feishu-plugin/.claude-plugin/plugin.json b/plugins/feishu-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..8df5502 --- /dev/null +++ b/plugins/feishu-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "feishu-plugin", + "description": "飞书多维表格快捷操作。通过自然语言实现多维表格的增删改查、数据同步、批量操作等功能。当用户提到飞书、多维表格、Bitable、飞书表格相关任务时自动激活。", + "version": "1.1.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/feishu-plugin/add_images.py b/plugins/feishu-plugin/add_images.py new file mode 100644 index 0000000..c61e568 --- /dev/null +++ b/plugins/feishu-plugin/add_images.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +添加需求图片字段并上传图片到飞书多维表格 +""" + +import requests +import os +from datetime import datetime, timedelta +from PIL import Image, ImageDraw, ImageFont + +# ========== 配置 ========== +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +# Demo 创建的多维表格信息 +APP_TOKEN = "D6PQbxf4aald77sPjDTciYbenjc" +TABLE_ID = "tblX3YbGrXm8pmLR" + + +class FeishuBitable: + """飞书多维表格操作工具类""" + + def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET): + self.app_id = app_id + self.app_secret = app_secret + self._token = None + self._token_expires = None + + @property + def token(self) -> str: + if self._token and self._token_expires and datetime.now() < self._token_expires: + return self._token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": self.app_id, + "app_secret": self.app_secret + }) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + self._token = data["tenant_access_token"] + self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return self._token + + @property + def headers(self): + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + def create_field(self, app_token: str, table_id: str, field_name: str, field_type: int, property: dict = None): + """创建新字段""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + payload = { + "field_name": field_name, + "type": field_type + } + if property: + payload["property"] = property + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建字段失败: {data}") + + print(f"[OK] 字段 '{field_name}' 创建成功") + return data["data"]["field"] + + def upload_media(self, app_token: str, file_path: str, file_name: str = None): + """上传附件到多维表格""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + + if file_name is None: + file_name = os.path.basename(file_path) + + file_size = os.path.getsize(file_path) + + headers = { + "Authorization": f"Bearer {self.token}" + } + + with open(file_path, 'rb') as f: + files = { + 'file': (file_name, f, 'image/png') + } + data = { + 'file_name': file_name, + 'parent_type': 'bitable_image', + 'parent_node': app_token, + 'size': str(file_size) + } + + response = requests.post(url, headers=headers, files=files, data=data) + + result = response.json() + if result.get("code") != 0: + raise Exception(f"上传失败: {result}") + + file_token = result["data"]["file_token"] + print(f"[OK] 文件 '{file_name}' 上传成功, file_token: {file_token}") + return file_token + + def list_records(self, app_token: str, table_id: str, filter_str: str = None): + """列出记录""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records" + params = {"page_size": 100} + if filter_str: + params["filter"] = filter_str + + response = requests.get(url, headers=self.headers, params=params) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"查询失败: {data}") + + return data["data"].get("items", []) + + def update_record(self, app_token: str, table_id: str, record_id: str, fields: dict): + """更新记录""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" + response = requests.put(url, headers=self.headers, json={"fields": fields}) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"更新失败: {data}") + + print(f"[OK] 记录 {record_id} 更新成功") + return data["data"]["record"] + + +def generate_sample_images(output_dir: str): + """生成2张示例需求图片""" + os.makedirs(output_dir, exist_ok=True) + images = [] + + # 图片1: 用户流程图 + img1 = Image.new('RGB', (800, 600), color='#f0f4f8') + draw1 = ImageDraw.Draw(img1) + + # 绘制标题 + draw1.rectangle([50, 30, 750, 80], fill='#4a90d9', outline='#2563eb') + draw1.text((400, 55), "用户登录流程图", fill='white', anchor='mm') + + # 绘制流程框 + boxes = [ + (150, 150, "打开登录页"), + (400, 150, "输入账号密码"), + (650, 150, "点击登录"), + (150, 300, "验证账号"), + (400, 300, "生成Token"), + (650, 300, "跳转首页"), + ] + for x, y, text in boxes: + draw1.rectangle([x-60, y-25, x+60, y+25], fill='#e8f4fd', outline='#4a90d9', width=2) + draw1.text((x, y), text, fill='#1e3a5f', anchor='mm') + + # 绘制箭头线 + arrows = [ + (210, 150, 340, 150), + (460, 150, 590, 150), + (650, 175, 650, 275), + (590, 300, 460, 300), + (340, 300, 210, 300), + ] + for x1, y1, x2, y2 in arrows: + draw1.line([(x1, y1), (x2, y2)], fill='#4a90d9', width=2) + + # 添加水印 + draw1.text((400, 550), "Claude Code Demo - 需求图片1", fill='#94a3b8', anchor='mm') + + img1_path = os.path.join(output_dir, "requirement_flow.png") + img1.save(img1_path) + images.append(img1_path) + print(f"[OK] 生成图片: {img1_path}") + + # 图片2: 界面原型图 + img2 = Image.new('RGB', (800, 600), color='#ffffff') + draw2 = ImageDraw.Draw(img2) + + # 绘制浏览器框架 + draw2.rectangle([50, 30, 750, 570], outline='#d1d5db', width=2) + draw2.rectangle([50, 30, 750, 70], fill='#f3f4f6', outline='#d1d5db') + + # 浏览器按钮 + draw2.ellipse([70, 42, 86, 58], fill='#ef4444') + draw2.ellipse([95, 42, 111, 58], fill='#eab308') + draw2.ellipse([120, 42, 136, 58], fill='#22c55e') + + # 地址栏 + draw2.rectangle([160, 42, 600, 58], fill='white', outline='#d1d5db') + draw2.text((170, 50), "https://example.com/login", fill='#6b7280', anchor='lm') + + # 登录表单区域 + draw2.rectangle([200, 120, 600, 500], fill='#f8fafc', outline='#e2e8f0', width=1) + + # Logo 占位 + draw2.ellipse([350, 140, 450, 200], fill='#4a90d9') + draw2.text((400, 170), "LOGO", fill='white', anchor='mm') + + # 标题 + draw2.text((400, 230), "欢迎登录", fill='#1e293b', anchor='mm') + + # 输入框 + draw2.rectangle([250, 270, 550, 310], fill='white', outline='#cbd5e1') + draw2.text((260, 290), "请输入用户名", fill='#94a3b8', anchor='lm') + + draw2.rectangle([250, 330, 550, 370], fill='white', outline='#cbd5e1') + draw2.text((260, 350), "请输入密码", fill='#94a3b8', anchor='lm') + + # 登录按钮 + draw2.rectangle([250, 400, 550, 450], fill='#4a90d9', outline='#2563eb') + draw2.text((400, 425), "登 录", fill='white', anchor='mm') + + # 底部链接 + draw2.text((320, 480), "忘记密码", fill='#4a90d9', anchor='mm') + draw2.text((480, 480), "注册账号", fill='#4a90d9', anchor='mm') + + # 水印 + draw2.text((400, 550), "Claude Code Demo - 需求图片2", fill='#94a3b8', anchor='mm') + + img2_path = os.path.join(output_dir, "requirement_ui.png") + img2.save(img2_path) + images.append(img2_path) + print(f"[OK] 生成图片: {img2_path}") + + return images + + +def main(): + print("\n" + "#" * 60) + print("# 添加需求图片到多维表格") + print("#" * 60) + + bitable = FeishuBitable() + + # Step 1: 生成示例图片 + print("\n" + "=" * 50) + print("Step 1: 生成示例图片") + print("=" * 50) + + output_dir = "/tmp/feishu_demo_images" + image_paths = generate_sample_images(output_dir) + + # Step 2: 添加"需求图片"字段 + print("\n" + "=" * 50) + print("Step 2: 添加「需求图片」字段") + print("=" * 50) + + try: + bitable.create_field( + APP_TOKEN, + TABLE_ID, + "需求图片", + 17 # 17 = 附件类型 + ) + except Exception as e: + if "FieldNameExist" in str(e) or "FieldNameDuplicated" in str(e) or "1254043" in str(e) or "1254014" in str(e): + print("[INFO] 字段「需求图片」已存在,跳过创建") + else: + raise + + # Step 3: 上传图片 + print("\n" + "=" * 50) + print("Step 3: 上传图片到飞书") + print("=" * 50) + + file_tokens = [] + for img_path in image_paths: + file_token = bitable.upload_media(APP_TOKEN, img_path) + file_tokens.append(file_token) + + # Step 4: 查找"完成产品需求文档"记录 + print("\n" + "=" * 50) + print("Step 4: 查找目标记录") + print("=" * 50) + + records = bitable.list_records(APP_TOKEN, TABLE_ID) + target_record = None + for record in records: + if record["fields"].get("任务名称") == "完成产品需求文档": + target_record = record + break + + if not target_record: + raise Exception("未找到「完成产品需求文档」记录") + + print(f"[OK] 找到记录: {target_record['record_id']}") + print(f" 任务名称: {target_record['fields'].get('任务名称')}") + + # Step 5: 更新记录,添加图片 + print("\n" + "=" * 50) + print("Step 5: 更新记录,添加图片附件") + print("=" * 50) + + # 构造附件字段值 + attachments = [{"file_token": ft} for ft in file_tokens] + + bitable.update_record( + APP_TOKEN, + TABLE_ID, + target_record["record_id"], + {"需求图片": attachments} + ) + + # 完成 + print("\n" + "=" * 60) + print("完成!") + print("=" * 60) + print(f"\n已添加 {len(file_tokens)} 张图片到「完成产品需求文档」记录") + print(f"\n访问地址查看效果:") + print(f" https://zhiyuncai.feishu.cn/base/{APP_TOKEN}?table={TABLE_ID}") + print() + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/aiproj_sync.py b/plugins/feishu-plugin/aiproj_sync.py new file mode 100644 index 0000000..8976535 --- /dev/null +++ b/plugins/feishu-plugin/aiproj_sync.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +""" +ai-proj 与飞书项目同步脚本 + +功能: +- 初始化:创建飞书根目录、多维表格 +- 全量同步:同步所有项目到飞书 +- 增量同步:只同步新增项目 +- 更新同步:更新现有项目的任务统计等信息 + +使用方法: + python aiproj_sync.py init # 首次初始化 + python aiproj_sync.py sync # 增量同步(推荐日常使用) + python aiproj_sync.py sync-all # 全量同步 + python aiproj_sync.py update # 更新统计信息 +""" + +import os +import sys +import json +import requests +from datetime import datetime +from typing import Optional, Dict, List, Any + +# ============================================ +# 配置 +# ============================================ + +FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef") +FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho") + +# 默认协作者 open_id(邱栋梁@智云采购) +# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者 +DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7" + +AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1" +AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64") + +CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json") + +# ============================================ +# 飞书 API +# ============================================ + +class FeishuAPI: + def __init__(self): + self.token = None + + def get_token(self) -> str: + """获取 tenant_access_token""" + if self.token: + return self.token + + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": FEISHU_APP_ID, + "app_secret": FEISHU_APP_SECRET + }) + data = response.json() + + if data.get("code") == 0: + self.token = data["tenant_access_token"] + return self.token + else: + raise Exception(f"获取飞书 token 失败: {data}") + + def _headers(self, content_type: bool = False) -> Dict: + headers = {"Authorization": f"Bearer {self.get_token()}"} + if content_type: + headers["Content-Type"] = "application/json" + return headers + + def get_root_folder(self) -> str: + """获取云空间根文件夹""" + url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta" + response = requests.get(url, headers=self._headers()) + data = response.json() + + if data.get("code") == 0: + return data["data"]["token"] + raise Exception(f"获取根文件夹失败: {data}") + + def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str: + """创建文件夹""" + url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder" + response = requests.post(url, headers=self._headers(True), json={ + "name": name, + "folder_token": parent_token + }) + data = response.json() + + if data.get("code") == 0: + folder_token = data["data"]["token"] + # 自动添加协作者(应用创建的文件夹默认只有应用能访问) + if add_collaborator and DEFAULT_COLLABORATOR_OPENID: + self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID) + return folder_token + raise Exception(f"创建文件夹失败: {data}") + + def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool: + """添加协作者 + + 应用通过 API 创建的文件/文件夹,所有者是应用本身。 + 组织内用户默认无法访问,需要显式添加为协作者。 + + Args: + file_token: 文件/文件夹 token + file_type: 类型 (folder, doc, sheet, bitable 等) + user_open_id: 用户 open_id + perm: 权限级别 (full_access, edit, view) + """ + url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members" + params = {"type": file_type, "need_notification": "false"} + payload = { + "member_type": "openid", + "member_id": user_open_id, + "perm": perm + } + response = requests.post(url, headers=self._headers(True), params=params, json=payload) + return response.json().get("code") == 0 + + def create_bitable(self, name: str, folder_token: str) -> Dict: + """创建多维表格""" + url = "https://open.feishu.cn/open-apis/bitable/v1/apps" + response = requests.post(url, headers=self._headers(True), json={ + "name": name, + "folder_token": folder_token + }) + data = response.json() + + if data.get("code") == 0: + return data["data"]["app"] + raise Exception(f"创建多维表格失败: {data}") + + def get_bitable_tables(self, app_token: str) -> List[Dict]: + """获取数据表列表""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables" + response = requests.get(url, headers=self._headers()) + data = response.json() + + if data.get("code") == 0: + return data["data"]["items"] + raise Exception(f"获取数据表失败: {data}") + + def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]: + """创建字段""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + response = requests.post(url, headers=self._headers(True), json=field) + data = response.json() + + if data.get("code") == 0: + return data["data"]["field"] + print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}") + return None + + def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool: + """更新字段""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}" + response = requests.put(url, headers=self._headers(True), json=updates) + return response.json().get("code") == 0 + + def get_fields(self, app_token: str, table_id: str) -> List[Dict]: + """获取字段列表""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + response = requests.get(url, headers=self._headers()) + data = response.json() + + if data.get("code") == 0: + return data["data"]["items"] + return [] + + def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]: + """添加记录""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + response = requests.post(url, headers=self._headers(True), json={"fields": fields}) + data = response.json() + + if data.get("code") == 0: + return data["data"]["record"] + print(f" 记录添加失败: {data.get('msg')}") + return None + + def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]: + """获取记录列表""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + params = {"page_size": 500} + if filter_str: + params["filter"] = filter_str + + response = requests.get(url, headers=self._headers(), params=params) + data = response.json() + + if data.get("code") == 0: + return data["data"].get("items", []) + return [] + + def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool: + """更新记录""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" + response = requests.put(url, headers=self._headers(True), json={"fields": fields}) + return response.json().get("code") == 0 + +# ============================================ +# ai-proj API +# ============================================ + +class AIProjAPI: + @staticmethod + def get_projects() -> List[Dict]: + """获取项目列表""" + url = f"{AIPROJ_API_BASE}/projects?page_size=100" + headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"} + response = requests.get(url, headers=headers) + data = response.json() + + if data.get("success"): + return data["data"]["data"] + raise Exception(f"获取项目列表失败: {data}") + +# ============================================ +# 配置管理 +# ============================================ + +def load_config() -> Optional[Dict]: + """加载配置""" + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH) as f: + return json.load(f) + return None + +def save_config(config: Dict): + """保存配置""" + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + +# ============================================ +# 同步逻辑 +# ============================================ + +def init(): + """初始化:创建飞书目录结构""" + print("=" * 50) + print("飞书 ai-proj 项目同步 - 初始化") + print("=" * 50) + + if load_config(): + print("\n已存在配置文件,是否重新初始化?(y/N)") + if input().lower() != 'y': + print("取消初始化") + return + + feishu = FeishuAPI() + + # 1. 获取根目录 + print("\n[1/5] 获取云空间根目录...") + space_root = feishu.get_root_folder() + print(f" 根目录: {space_root}") + + # 2. 创建 ai-proj 文件夹 + print("\n[2/5] 创建 ai-proj 根文件夹...") + root_folder = feishu.create_folder("ai-proj", space_root) + print(f" 创建成功: {root_folder}") + + # 3. 创建多维表格 + print("\n[3/5] 创建项目目录多维表格...") + bitable = feishu.create_bitable("项目目录", root_folder) + app_token = bitable["app_token"] + print(f" 创建成功: {app_token}") + + tables = feishu.get_bitable_tables(app_token) + table_id = tables[0]["table_id"] + + # 4. 创建字段 + print("\n[4/5] 创建数据表字段...") + + # 先修改第一列名称 + fields = feishu.get_fields(app_token, table_id) + if fields: + feishu.update_field(app_token, table_id, fields[0]["field_id"], { + "field_name": "项目名称", + "type": fields[0]["type"] + }) + print(" 修改字段: 项目名称") + + # 创建其他字段 + field_definitions = [ + {"field_name": "项目ID", "type": 2}, + {"field_name": "项目编号", "type": 1}, + {"field_name": "飞书文件夹", "type": 15}, + {"field_name": "状态", "type": 3, "property": {"options": [ + {"name": "active", "color": 0}, + {"name": "on_hold", "color": 1}, + {"name": "planning", "color": 2}, + {"name": "completed", "color": 3}, + {"name": "archived", "color": 4} + ]}}, + {"field_name": "优先级", "type": 3, "property": {"options": [ + {"name": "high", "color": 0}, + {"name": "medium", "color": 1}, + {"name": "low", "color": 2} + ]}}, + {"field_name": "所属企业", "type": 1}, + {"field_name": "任务总数", "type": 2}, + {"field_name": "描述", "type": 1}, + {"field_name": "创建时间", "type": 5}, + {"field_name": "最后同步", "type": 5}, + ] + + for field in field_definitions: + if feishu.create_field(app_token, table_id, field): + print(f" 创建字段: {field['field_name']}") + + # 5. 保存配置 + print("\n[5/5] 保存配置...") + config = { + "root_folder_token": root_folder, + "bitable_app_token": app_token, + "bitable_table_id": table_id, + "created_at": datetime.now().isoformat(), + "synced_projects": {} + } + save_config(config) + + print("\n" + "=" * 50) + print("初始化完成!") + print("=" * 50) + print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}") + print(f"多维表格: https://feishu.cn/base/{app_token}") + print(f"\n配置文件: {CONFIG_PATH}") + print("\n运行 'python aiproj_sync.py sync' 同步项目") + +def sync(full: bool = False): + """同步项目""" + config = load_config() + if not config: + print("未找到配置,请先运行 init") + return + + print("=" * 50) + print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步") + print("=" * 50) + + feishu = FeishuAPI() + app_token = config["bitable_app_token"] + table_id = config["bitable_table_id"] + root_folder = config["root_folder_token"] + synced = config.get("synced_projects", {}) + + # 获取 ai-proj 项目 + print("\n获取 ai-proj 项目列表...") + projects = AIProjAPI.get_projects() + print(f" 共 {len(projects)} 个项目") + + # 筛选需要同步的项目 + if full: + to_sync = projects + else: + to_sync = [p for p in projects if str(p["id"]) not in synced] + + if not to_sync: + print("\n没有新项目需要同步") + return + + print(f"\n需要同步 {len(to_sync)} 个项目...") + + success = 0 + for project in to_sync: + project_id = str(project["id"]) + project_name = project["name"] + folder_name = f"{project_name}_{project_id}" + + print(f"\n 处理: {project_name} (ID: {project_id})") + + # 创建文件夹(如果不存在) + folder_token = synced.get(project_id, {}).get("folder_token") + if not folder_token: + try: + folder_token = feishu.create_folder(folder_name, root_folder) + print(f" 创建文件夹: {folder_token}") + except Exception as e: + print(f" 文件夹创建失败: {e}") + folder_token = "" + + folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else "" + + # 添加/更新记录 + record_fields = { + "项目名称": project_name, + "项目ID": int(project_id), + "项目编号": project.get("project_number", ""), + "飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None, + "状态": project.get("status", "active"), + "优先级": project.get("priority", "medium"), + "所属企业": project.get("company_name", ""), + "任务总数": project.get("task_count", 0), + "描述": (project.get("description", "") or "")[:500], + "创建时间": int(datetime.fromisoformat( + project["created_at"].replace("Z", "+00:00") + ).timestamp() * 1000), + "最后同步": int(datetime.now().timestamp() * 1000) + } + + record_id = synced.get(project_id, {}).get("record_id") + if record_id and not full: + # 更新现有记录 + if feishu.update_record(app_token, table_id, record_id, record_fields): + print(f" 更新记录成功") + success += 1 + else: + # 添加新记录 + record = feishu.add_record(app_token, table_id, record_fields) + if record: + record_id = record["record_id"] + print(f" 添加记录成功") + success += 1 + + # 更新同步记录 + synced[project_id] = { + "folder_token": folder_token, + "folder_url": folder_url, + "record_id": record_id, + "synced_at": datetime.now().isoformat() + } + + # 保存配置 + config["synced_projects"] = synced + config["last_sync"] = datetime.now().isoformat() + save_config(config) + + print("\n" + "=" * 50) + print(f"同步完成: {success}/{len(to_sync)}") + print("=" * 50) + print(f"\n多维表格: https://feishu.cn/base/{app_token}") + +def update_stats(): + """更新任务统计信息""" + config = load_config() + if not config: + print("未找到配置,请先运行 init") + return + + print("=" * 50) + print("飞书 ai-proj 项目同步 - 更新统计") + print("=" * 50) + + feishu = FeishuAPI() + app_token = config["bitable_app_token"] + table_id = config["bitable_table_id"] + synced = config.get("synced_projects", {}) + + # 获取最新项目数据 + print("\n获取 ai-proj 项目列表...") + projects = AIProjAPI.get_projects() + project_map = {str(p["id"]): p for p in projects} + + updated = 0 + for project_id, sync_info in synced.items(): + record_id = sync_info.get("record_id") + if not record_id: + continue + + project = project_map.get(project_id) + if not project: + continue + + # 只更新统计字段 + update_fields = { + "任务总数": project.get("task_count", 0), + "状态": project.get("status", "active"), + "最后同步": int(datetime.now().timestamp() * 1000) + } + + if feishu.update_record(app_token, table_id, record_id, update_fields): + print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}") + updated += 1 + + config["last_sync"] = datetime.now().isoformat() + save_config(config) + + print(f"\n更新完成: {updated} 个项目") + +def show_status(): + """显示同步状态""" + config = load_config() + if not config: + print("未初始化,请先运行: python aiproj_sync.py init") + return + + print("=" * 50) + print("ai-proj 飞书同步状态") + print("=" * 50) + print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}") + print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}") + print(f"已同步项目: {len(config.get('synced_projects', {}))}") + print(f"上次同步: {config.get('last_sync', '从未')}") + print(f"配置文件: {CONFIG_PATH}") + +# ============================================ +# 主入口 +# ============================================ + +def main(): + if len(sys.argv) < 2: + print("用法: python aiproj_sync.py ") + print("\n命令:") + print(" init 首次初始化(创建目录和多维表格)") + print(" sync 增量同步(只同步新项目)") + print(" sync-all 全量同步(同步所有项目)") + print(" update 更新统计信息") + print(" status 查看同步状态") + return + + command = sys.argv[1] + + if command == "init": + init() + elif command == "sync": + sync(full=False) + elif command == "sync-all": + sync(full=True) + elif command == "update": + update_stats() + elif command == "status": + show_status() + else: + print(f"未知命令: {command}") + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/check_docx_image.py b/plugins/feishu-plugin/check_docx_image.py new file mode 100644 index 0000000..d19dc03 --- /dev/null +++ b/plugins/feishu-plugin/check_docx_image.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +检查飞书云文档中的图片块状态 +""" + +import requests +from datetime import datetime, timedelta + +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +# 最近创建的测试文档 +DOCUMENT_ID = "Z53YdDpezob1NPx63sQcsrt8nzd" + + +def get_token(): + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + return data["tenant_access_token"] + + +def get_document_blocks(document_id: str): + """获取文档所有块""" + token = get_token() + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get(url, headers=headers) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取块失败: {data}") + + return data["data"].get("items", []) + + +def main(): + print(f"\n检查文档: {DOCUMENT_ID}") + print("=" * 60) + + blocks = get_document_blocks(DOCUMENT_ID) + + print(f"\n文档共有 {len(blocks)} 个块:\n") + + for i, block in enumerate(blocks): + block_type = block.get("block_type") + block_id = block.get("block_id") + + # 块类型映射 + type_names = { + 1: "page", + 2: "text", + 3: "heading1", + 4: "heading2", + 5: "heading3", + 12: "bullet", + 13: "ordered", + 14: "code", + 17: "todo", + 22: "divider", + 27: "image", + } + type_name = type_names.get(block_type, f"type_{block_type}") + + print(f" [{i}] block_type={block_type} ({type_name}), block_id={block_id[:20]}...") + + # 如果是图片块,显示详细信息 + if block_type == 27: + image_data = block.get("image", {}) + print(f" image data: {image_data}") + + # 检查图片是否有效 + file_token = image_data.get("token") or image_data.get("file_token") + if file_token: + print(f" file_token: {file_token}") + # 尝试获取图片信息 + check_image_status(file_token) + else: + print(f" [WARN] 图片块没有 token!") + + +def check_image_status(file_token: str): + """检查图片状态""" + token = get_token() + + # 尝试获取文件元信息 + url = f"{BASE_URL}/drive/v1/medias/{file_token}" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get(url, headers=headers) + data = response.json() + + print(f" 图片状态: code={data.get('code')}, msg={data.get('msg')}") + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/check_manual_images.py b/plugins/feishu-plugin/check_manual_images.py new file mode 100644 index 0000000..7f2386f --- /dev/null +++ b/plugins/feishu-plugin/check_manual_images.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""检查文档中的图片块状态""" + +import requests +from datetime import datetime, timedelta + +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" +DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf" + +def get_token(): + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + return response.json()["tenant_access_token"] + +def main(): + token = get_token() + headers = {"Authorization": f"Bearer {token}"} + + url = f"{BASE_URL}/docx/v1/documents/{DOCUMENT_ID}/blocks" + response = requests.get(url, headers=headers) + data = response.json() + + if data.get("code") != 0: + print(f"获取失败: {data}") + return + + blocks = data["data"].get("items", []) + print(f"文档共有 {len(blocks)} 个块\n") + + image_count = 0 + for block in blocks: + if block.get("block_type") == 27: + image_count += 1 + image_data = block.get("image", {}) + token_value = image_data.get("token", "") + print(f"图片块 #{image_count}:") + print(f" block_id: {block.get('block_id')}") + print(f" token: {token_value if token_value else '(空)'}") + print(f" width: {image_data.get('width')}, height: {image_data.get('height')}") + print() + + if image_count == 0: + print("文档中没有图片块") + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/create_visibility_manual.py b/plugins/feishu-plugin/create_visibility_manual.py new file mode 100644 index 0000000..fff37fc --- /dev/null +++ b/plugins/feishu-plugin/create_visibility_manual.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" +创建新的飞书文档:ai-proj 项目可见性手册 +""" + +import requests +import os +import time +from datetime import datetime, timedelta +from PIL import Image, ImageDraw, ImageFont + +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +_token = None +_token_expires = None + + +def get_token(): + global _token, _token_expires + if _token and _token_expires and datetime.now() < _token_expires: + return _token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + _token = data["tenant_access_token"] + _token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return _token + + +def headers(): + return { + "Authorization": f"Bearer {get_token()}", + "Content-Type": "application/json" + } + + +def create_document(title: str): + """创建文档""" + url = f"{BASE_URL}/docx/v1/documents" + response = requests.post(url, headers=headers(), json={"title": title}) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建文档失败: {data}") + doc = data["data"]["document"] + document_id = doc["document_id"] + print(f"[OK] 文档创建成功: {document_id}") + return document_id + + +def set_document_permission(document_id: str): + """设置文档权限为组织内可编辑""" + url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public" + payload = { + "external_access_entity": "open", + "security_entity": "anyone_can_view", + "comment_entity": "anyone_can_view", + "share_entity": "anyone", + "link_share_entity": "tenant_editable", + "invite_external": False + } + response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload) + result = response.json() + if result.get("code") == 0: + print("[OK] 权限设置成功: 组织内可编辑") + return True + else: + print(f"[WARN] 权限设置: {result.get('msg')}") + return False + + +def get_document_blocks(document_id: str): + """获取文档所有块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks" + response = requests.get(url, headers=headers()) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取块失败: {data}") + return data["data"].get("items", []) + + +def create_blocks(document_id: str, parent_id: str, blocks: list): + """创建内容块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + payload = {"children": blocks} + response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建块失败: {data}") + return data["data"].get("children", []) + + +def create_image_block(document_id: str, parent_id: str): + """创建空图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + payload = { + "children": [{ + "block_type": 27, + "image": {} + }] + } + response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建图片块失败: {data}") + return data["data"]["children"][0]["block_id"] + + +def upload_image(file_path: str, block_id: str): + """上传图片""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': 'docx_image', + 'parent_node': block_id, + 'size': str(file_size) + } + response = requests.post( + url, + headers={"Authorization": f"Bearer {get_token()}"}, + files=files, + data=data + ) + + result = response.json() + if result.get("code") != 0: + raise Exception(f"上传失败: {result}") + return result["data"]["file_token"] + + +def bind_image(document_id: str, block_id: str, file_token: str): + """绑定图片到图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + payload = {"replace_image": {"token": file_token}} + response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"绑定失败: {data}") + return True + + +def insert_image(document_id: str, file_path: str, description: str = ""): + """插入图片的完整流程:创建块 -> 上传 -> 绑定""" + print(f" 插入图片: {description}") + + # Step 1: 创建空图片块 + block_id = create_image_block(document_id, document_id) + print(f" block_id: {block_id}") + + # Step 2: 上传图片 + file_token = upload_image(file_path, block_id) + print(f" file_token: {file_token}") + + # Step 3: 绑定图片到块 + bind_image(document_id, block_id, file_token) + print(f" 绑定成功!") + + # 等待处理 + time.sleep(0.5) + + return block_id, file_token + + +def generate_visibility_diagram(): + """生成可见性示意图""" + output_path = "/tmp/visibility_diagram.png" + + img = Image.new('RGB', (800, 400), color='#ffffff') + draw = ImageDraw.Draw(img) + + # 尝试加载字体 + try: + font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24) + font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18) + font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14) + except: + font_large = ImageFont.load_default() + font_medium = ImageFont.load_default() + font_small = ImageFont.load_default() + + # 标题背景 + draw.rectangle([0, 0, 800, 60], fill='#1890ff') + draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large) + + # 私有项目区域 + draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3) + draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium) + draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small) + draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small) + + # 私有项目图标 + draw.ellipse([175, 210, 245, 280], fill='#fa8c16') + draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large) + draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small) + draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small) + + # 企业项目区域 + draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3) + draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium) + draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small) + draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small) + + # 企业项目图标组 + positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)] + for x, y in positions: + draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff') + draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium) + + draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small) + + # 中间箭头 + draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large) + + img.save(output_path) + print(f"[OK] 生成可见性示意图: {output_path}") + return output_path + + +def generate_ui_screenshot(): + """生成 UI 示意图""" + output_path = "/tmp/visibility_ui.png" + + img = Image.new('RGB', (700, 250), color='#fafafa') + draw = ImageDraw.Draw(img) + + try: + font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16) + font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12) + except: + font_medium = ImageFont.load_default() + font_small = ImageFont.load_default() + + # 标题 + draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium) + + # 私有项目选项(选中状态) + draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2) + draw.ellipse([45, 95, 65, 115], fill='#fa8c16') + draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium) + draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small) + + # 默认标签 + draw.rectangle([250, 85, 310, 110], fill='#52c41a') + draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small) + + # 企业项目选项(未选中状态) + draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1) + draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2) + draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium) + draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small) + + # 底部说明 + draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1) + draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small) + draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small) + + img.save(output_path) + print(f"[OK] 生成 UI 示意图: {output_path}") + return output_path + + +# 文档内容块定义 +def heading1(text): + return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}} + +def heading2(text): + return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}} + +def heading3(text): + return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}} + +def text_block(content): + return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}} + +def bullet(content): + return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}} + +def ordered(content): + return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}} + +def code_block(content, language=1): + return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}} + +def divider(): + return {"block_type": 22, "divider": {}} + + +def main(): + print("\n" + "#" * 60) + print("# 创建新文档:ai-proj 项目可见性手册") + print("#" * 60) + + # Step 1: 生成示意图 + print("\n--- Step 1: 生成示意图 ---") + diagram_path = generate_visibility_diagram() + ui_path = generate_ui_screenshot() + + # Step 2: 创建新文档 + print("\n--- Step 2: 创建新文档 ---") + doc_id = create_document("ai-proj 项目可见性手册") + set_document_permission(doc_id) + + # Step 3: 创建手册内容 + print("\n--- Step 3: 创建手册内容 ---") + + # 第一部分:标题和概述 + blocks_part1 = [ + heading1("ai-proj 项目可见性手册"), + text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"), + divider(), + heading2("1. 功能概述"), + text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"), + bullet("私有项目 (private):仅项目创建者和管理员可见"), + bullet("企业项目 (enterprise):企业内所有成员可见"), + text_block("新创建的项目默认为「私有项目」,保护您的隐私。"), + divider(), + heading2("2. 可见性对比"), + text_block("下图展示了两种可见性的区别:"), + ] + create_blocks(doc_id, doc_id, blocks_part1) + print(" 第一部分内容创建完成") + + # 插入可见性示意图 + print("\n--- Step 4: 插入可见性示意图 ---") + insert_image(doc_id, diagram_path, "可见性示意图") + + # 第二部分:设置方法 + print("\n--- Step 5: 添加设置说明 ---") + blocks_part2 = [ + divider(), + heading2("3. 设置项目可见性"), + heading3("3.1 创建项目时设置"), + text_block("在创建项目时,您可以选择项目的可见性:"), + ordered("点击「创建项目」按钮"), + ordered("在弹出的表单中找到「项目可见性」选项"), + ordered("选择「私有项目」或「企业项目」"), + ordered("填写其他信息后点击「确定」"), + text_block("UI 界面示意:"), + ] + create_blocks(doc_id, doc_id, blocks_part2) + print(" 设置说明创建完成") + + # 插入 UI 示意图 + print("\n--- Step 6: 插入 UI 示意图 ---") + insert_image(doc_id, ui_path, "UI 示意图") + + # 第三部分:修改方法和规则 + print("\n--- Step 7: 添加剩余内容 ---") + blocks_part3 = [ + heading3("3.2 修改已有项目"), + ordered("在项目列表中找到目标项目"), + ordered("点击项目卡片上的「编辑」按钮"), + ordered("在编辑页面修改「项目可见性」"), + ordered("点击「保存」"), + divider(), + heading2("4. 可见性规则"), + bullet("私有项目:仅所有者和超级管理员可以查看"), + bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"), + bullet("只有项目所有者或管理员可以修改可见性"), + bullet("设置为「企业项目」需要项目已关联企业"), + divider(), + heading2("5. API 参考"), + text_block("修改项目可见性的 API:"), + code_block('''PATCH /api/v1/projects/:id/visibility + +请求体: +{ + "visibility": "enterprise" +} + +可选值: "private" | "enterprise"'''), + divider(), + heading2("6. 注意事项"), + bullet("新项目默认为私有,请根据需要调整可见性"), + bullet("将私有项目改为企业项目后,企业所有成员都能看到"), + bullet("将企业项目改为私有后,其他成员将无法访问"), + bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"), + divider(), + text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"), + ] + create_blocks(doc_id, doc_id, blocks_part3) + + print("\n" + "=" * 60) + print("文档创建完成!") + print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{doc_id}") + print("=" * 60) + + # 验证图片 + print("\n--- 验证图片状态 ---") + time.sleep(2) # 等待服务器处理 + blocks = get_document_blocks(doc_id) + image_count = 0 + for block in blocks: + if block.get("block_type") == 27: + image_count += 1 + image_data = block.get("image", {}) + token = image_data.get("token", "") + width = image_data.get("width", 0) + height = image_data.get("height", 0) + status = "✅ 有效" if token else "❌ 空" + print(f"图片 #{image_count}: {status} (token: {token[:15]}..., 尺寸: {width}x{height})") + + if image_count == 0: + print("⚠️ 警告:文档中没有图片块") + elif image_count == 2: + print(f"\n✅ 成功上传 {image_count} 张图片") + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/debug_image.py b/plugins/feishu-plugin/debug_image.py new file mode 100644 index 0000000..6b944b9 --- /dev/null +++ b/plugins/feishu-plugin/debug_image.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +深入调试飞书云文档图片上传 +""" + +import requests +import os +from datetime import datetime, timedelta + +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +_token = None +_token_expires = None + + +def get_token(): + global _token, _token_expires + if _token and _token_expires and datetime.now() < _token_expires: + return _token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + _token = data["tenant_access_token"] + _token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return _token + + +def headers(): + return { + "Authorization": f"Bearer {get_token()}", + "Content-Type": "application/json" + } + + +def set_document_permission(document_id: str, editable: bool = True): + """设置文档权限""" + url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public" + payload = { + "external_access_entity": "open", + "security_entity": "anyone_can_view", + "comment_entity": "anyone_can_view", + "share_entity": "anyone", + "link_share_entity": "tenant_editable" if editable else "tenant_readable", + "invite_external": False + } + response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload) + return response.json().get("code") == 0 + + +def create_document(title: str, editable: bool = True): + """创建文档(自动设置为组织内可编辑)""" + url = f"{BASE_URL}/docx/v1/documents" + response = requests.post(url, headers=headers(), json={"title": title}) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建文档失败: {data}") + doc = data["data"]["document"] + document_id = doc["document_id"] + print(f"[OK] 文档创建成功: {document_id}") + + # 自动设置权限 + if editable and set_document_permission(document_id, True): + print(f"[OK] 权限设置成功: 组织内可编辑") + + return document_id + + +def get_raw_content(document_id: str): + """获取文档原始内容""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/raw_content" + response = requests.get(url, headers=headers()) + data = response.json() + print(f"[DEBUG] raw_content 响应: {data}") + return data + + +def get_blocks(document_id: str): + """获取文档块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks" + + # 尝试添加 document_revision_id 参数 + params = {"document_revision_id": -1} # -1 表示最新版本 + + response = requests.get(url, headers=headers(), params=params) + data = response.json() + return data + + +def create_image_block(document_id: str): + """创建图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children" + + # 添加 document_revision_id 参数 + params = {"document_revision_id": -1} + + payload = { + "children": [{ + "block_type": 27, + "image": {} + }] + } + + response = requests.post(url, headers=headers(), params=params, json=payload) + data = response.json() + print(f"[DEBUG] 创建图片块响应: {data}") + + if data.get("code") != 0: + raise Exception(f"创建图片块失败: {data}") + + block_id = data["data"]["children"][0]["block_id"] + print(f"[OK] 图片块创建成功: {block_id}") + return block_id + + +def upload_image(file_path: str, block_id: str): + """上传图片""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': 'docx_image', + 'parent_node': block_id, + 'size': str(file_size) + } + + response = requests.post( + url, + headers={"Authorization": f"Bearer {get_token()}"}, + files=files, + data=data + ) + + result = response.json() + print(f"[DEBUG] 上传响应: {result}") + + if result.get("code") != 0: + raise Exception(f"上传失败: {result}") + + return result["data"]["file_token"] + + +def bind_image_to_block(document_id: str, block_id: str, file_token: str): + """ + 绑定图片到图片块 (关键步骤!) + + 使用 replace_image 字段通过 PATCH 请求绑定 + """ + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + + params = {"document_revision_id": -1} + + # 正确的 payload: 使用 replace_image + payload = { + "replace_image": { + "token": file_token + } + } + + print(f"[INFO] 绑定图片: PATCH {url}") + print(f"[INFO] payload: {payload}") + + response = requests.patch(url, headers=headers(), params=params, json=payload) + result = response.json() + print(f"[DEBUG] 绑定响应: {result}") + + if result.get("code") == 0: + print(f"[OK] 图片绑定成功!") + return True + else: + print(f"[ERROR] 图片绑定失败: code={result.get('code')}, msg={result.get('msg')}") + return False + + +def main(): + print("\n" + "#" * 60) + print("# 深入调试飞书云文档图片上传") + print("#" * 60) + + # 生成测试图片 + from PIL import Image, ImageDraw + test_image = "/tmp/debug_test_image.png" + img = Image.new('RGB', (200, 150), color='#4a90d9') + draw = ImageDraw.Draw(img) + draw.text((100, 75), "Test", fill='white', anchor='mm') + img.save(test_image) + print(f"[OK] 测试图片: {test_image}") + + # Step 1: 创建文档 + print("\n--- Step 1: 创建文档 ---") + doc_id = create_document(f"调试图片上传 - {datetime.now().strftime('%H:%M:%S')}") + + # Step 2: 创建图片块 + print("\n--- Step 2: 创建图片块 ---") + block_id = create_image_block(doc_id) + + # Step 3: 上传图片 + print("\n--- Step 3: 上传图片 ---") + file_token = upload_image(test_image, block_id) + print(f"[OK] file_token: {file_token}") + + # Step 4: 检查块状态 + print("\n--- Step 4: 检查块状态 ---") + blocks = get_blocks(doc_id) + for item in blocks.get("data", {}).get("items", []): + if item.get("block_type") == 27: + print(f"[INFO] 图片块: {item}") + + # Step 5: 绑定图片到图片块 (关键步骤!) + print("\n--- Step 5: 绑定图片到图片块 ---") + bind_image_to_block(doc_id, block_id, file_token) + + # Step 6: 再次检查 + print("\n--- Step 6: 再次检查块状态 ---") + import time + time.sleep(2) # 等待2秒 + blocks = get_blocks(doc_id) + for item in blocks.get("data", {}).get("items", []): + if item.get("block_type") == 27: + print(f"[INFO] 图片块: {item}") + + print(f"\n文档地址: https://feishu.cn/docx/{doc_id}") + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/demo.py b/plugins/feishu-plugin/demo.py new file mode 100644 index 0000000..342bc3f --- /dev/null +++ b/plugins/feishu-plugin/demo.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +""" +飞书多维表格 Demo +使用 zhiyun.ai 凭证演示多维表格的完整操作流程 +""" + +import requests +from datetime import datetime, timedelta +from typing import Optional, List, Dict + +# ========== 配置 ========== +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + + +# ========== 工具类 ========== +class FeishuBitable: + """飞书多维表格操作工具类""" + + def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET): + self.app_id = app_id + self.app_secret = app_secret + self._token = None + self._token_expires = None + + @property + def token(self) -> str: + """获取或刷新 access token""" + if self._token and self._token_expires and datetime.now() < self._token_expires: + return self._token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": self.app_id, + "app_secret": self.app_secret + }) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + self._token = data["tenant_access_token"] + self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + print(f"[OK] Token 获取成功,有效期至 {self._token_expires}") + return self._token + + @property + def headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + # ========== 多维表格管理 ========== + def create_bitable(self, name: str, folder_token: str = None) -> Dict: + """ + 创建新的多维表格 + + Args: + name: 多维表格名称 + folder_token: 文件夹 token(可选,不指定则创建在根目录) + """ + url = f"{BASE_URL}/bitable/v1/apps" + payload = {"name": name} + if folder_token: + payload["folder_token"] = folder_token + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建多维表格失败: {data}") + + app_info = data["data"]["app"] + print(f"[OK] 多维表格创建成功") + print(f" 名称: {app_info['name']}") + print(f" app_token: {app_info['app_token']}") + print(f" URL: {app_info.get('url', 'N/A')}") + return app_info + + def list_tables(self, app_token: str) -> List[Dict]: + """列出多维表格中的所有数据表""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables" + response = requests.get(url, headers=self.headers) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取数据表列表失败: {data}") + + tables = data["data"].get("items", []) + print(f"[OK] 找到 {len(tables)} 个数据表") + for t in tables: + print(f" - {t['name']} (table_id: {t['table_id']})") + return tables + + def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict: + """ + 在多维表格中创建数据表 + + Args: + app_token: 多维表格 app_token + name: 数据表名称 + fields: 字段定义列表 + """ + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables" + payload = { + "table": { + "name": name, + "default_view_name": "默认视图", + "fields": fields + } + } + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建数据表失败: {data}") + + table_info = data["data"] + print(f"[OK] 数据表创建成功") + print(f" 名称: {name}") + print(f" table_id: {table_info['table_id']}") + return table_info + + # ========== 记录操作 ========== + def list_records(self, app_token: str, table_id: str, + filter_str: str = None, page_size: int = 100) -> List[Dict]: + """列出所有记录(自动分页)""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records" + all_records = [] + page_token = None + + while True: + params = {"page_size": min(page_size, 500)} + if page_token: + params["page_token"] = page_token + if filter_str: + params["filter"] = filter_str + + response = requests.get(url, headers=self.headers, params=params) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"查询失败: {data}") + + items = data["data"].get("items", []) + all_records.extend(items) + + if not data["data"].get("has_more"): + break + page_token = data["data"]["page_token"] + + return all_records + + def create_record(self, app_token: str, table_id: str, fields: Dict) -> Dict: + """创建单条记录""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records" + response = requests.post(url, headers=self.headers, json={"fields": fields}) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建失败: {data}") + return data["data"]["record"] + + def batch_create(self, app_token: str, table_id: str, + records: List[Dict], batch_size: int = 500) -> List[Dict]: + """批量创建记录""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create" + created = [] + + for i in range(0, len(records), batch_size): + batch = [{"fields": r} if "fields" not in r else r for r in records[i:i+batch_size]] + response = requests.post(url, headers=self.headers, json={"records": batch}) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"批量创建失败: {data}") + created.extend(data["data"]["records"]) + + return created + + def get_fields(self, app_token: str, table_id: str) -> List[Dict]: + """获取字段定义""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + response = requests.get(url, headers=self.headers) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取字段失败: {data}") + return data["data"]["items"] + + +# ========== Demo 函数 ========== +def demo_verify_credentials(): + """验证凭证是否有效""" + print("\n" + "="*50) + print("Step 1: 验证飞书应用凭证") + print("="*50) + + bitable = FeishuBitable() + token = bitable.token # 触发 token 获取 + print(f" Token 前缀: {token[:20]}...") + return bitable + + +def demo_create_bitable(bitable: FeishuBitable) -> str: + """创建新的多维表格""" + print("\n" + "="*50) + print("Step 2: 创建多维表格") + print("="*50) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + name = f"Claude Code Demo - {timestamp}" + + app_info = bitable.create_bitable(name) + return app_info["app_token"] + + +def demo_create_task_table(bitable: FeishuBitable, app_token: str) -> str: + """创建任务管理数据表""" + print("\n" + "="*50) + print("Step 3: 创建任务管理数据表") + print("="*50) + + # 定义字段 + fields = [ + { + "field_name": "任务名称", + "type": 1 # 文本 + }, + { + "field_name": "状态", + "type": 3, # 单选 + "property": { + "options": [ + {"name": "待处理", "color": 0}, + {"name": "进行中", "color": 1}, + {"name": "已完成", "color": 2}, + {"name": "已取消", "color": 3} + ] + } + }, + { + "field_name": "优先级", + "type": 3, # 单选 + "property": { + "options": [ + {"name": "高", "color": 4}, + {"name": "中", "color": 5}, + {"name": "低", "color": 6} + ] + } + }, + { + "field_name": "负责人", + "type": 1 # 文本 + }, + { + "field_name": "截止日期", + "type": 5, # 日期 + "property": { + "date_formatter": "yyyy/MM/dd" + } + }, + { + "field_name": "工时(小时)", + "type": 2 # 数字 + }, + { + "field_name": "备注", + "type": 1 # 文本 + } + ] + + table_info = bitable.create_table(app_token, "任务看板", fields) + return table_info["table_id"] + + +def demo_add_sample_data(bitable: FeishuBitable, app_token: str, table_id: str): + """添加示例数据""" + print("\n" + "="*50) + print("Step 4: 添加示例数据") + print("="*50) + + # 准备示例数据 + now = datetime.now() + sample_tasks = [ + { + "任务名称": "完成产品需求文档", + "状态": "已完成", + "优先级": "高", + "负责人": "张三", + "截止日期": int((now - timedelta(days=2)).timestamp() * 1000), + "工时(小时)": 8, + "备注": "PRD 已评审通过" + }, + { + "任务名称": "设计系统架构方案", + "状态": "进行中", + "优先级": "高", + "负责人": "李四", + "截止日期": int((now + timedelta(days=3)).timestamp() * 1000), + "工时(小时)": 16, + "备注": "正在编写技术方案" + }, + { + "任务名称": "开发用户登录模块", + "状态": "待处理", + "优先级": "中", + "负责人": "王五", + "截止日期": int((now + timedelta(days=7)).timestamp() * 1000), + "工时(小时)": 24, + "备注": "等待架构方案确定" + }, + { + "任务名称": "编写单元测试", + "状态": "待处理", + "优先级": "中", + "负责人": "赵六", + "截止日期": int((now + timedelta(days=10)).timestamp() * 1000), + "工时(小时)": 12, + "备注": "" + }, + { + "任务名称": "部署测试环境", + "状态": "待处理", + "优先级": "低", + "负责人": "钱七", + "截止日期": int((now + timedelta(days=14)).timestamp() * 1000), + "工时(小时)": 4, + "备注": "需要申请服务器资源" + } + ] + + # 批量创建记录 + created = bitable.batch_create(app_token, table_id, sample_tasks) + print(f"[OK] 成功创建 {len(created)} 条示例记录") + + # 显示创建的记录 + for i, record in enumerate(created, 1): + fields = record["fields"] + print(f" {i}. {fields.get('任务名称')} - {fields.get('状态')} ({fields.get('优先级')}优先级)") + + return created + + +def demo_query_data(bitable: FeishuBitable, app_token: str, table_id: str): + """查询数据演示""" + print("\n" + "="*50) + print("Step 5: 查询数据演示") + print("="*50) + + # 查询所有记录 + all_records = bitable.list_records(app_token, table_id) + print(f"[OK] 共 {len(all_records)} 条记录") + + # 获取字段定义 + fields = bitable.get_fields(app_token, table_id) + print(f"[OK] 共 {len(fields)} 个字段:") + for f in fields: + print(f" - {f['field_name']} (类型: {f['type']})") + + return all_records + + +def main(): + """运行完整 Demo""" + print("\n" + "#"*60) + print("# 飞书多维表格 Demo - zhiyun.ai") + print("#"*60) + + try: + # Step 1: 验证凭证 + bitable = demo_verify_credentials() + + # Step 2: 创建多维表格 + app_token = demo_create_bitable(bitable) + + # Step 3: 创建数据表 + table_id = demo_create_task_table(bitable, app_token) + + # Step 4: 添加示例数据 + demo_add_sample_data(bitable, app_token, table_id) + + # Step 5: 查询数据 + demo_query_data(bitable, app_token, table_id) + + # 完成 + print("\n" + "="*60) + print("Demo 完成!") + print("="*60) + print(f"\n多维表格信息:") + print(f" app_token: {app_token}") + print(f" table_id: {table_id}") + print(f"\n访问地址:") + print(f" https://zhiyun-ai.feishu.cn/base/{app_token}?table={table_id}") + print() + + return app_token, table_id + + except Exception as e: + print(f"\n[ERROR] {e}") + raise + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/feishu_docx.py b/plugins/feishu-plugin/feishu_docx.py new file mode 100644 index 0000000..84615b7 --- /dev/null +++ b/plugins/feishu-plugin/feishu_docx.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +""" +飞书云文档操作工具类 +包含文档创建、内容块管理、图片上传等功能 +""" + +import os +import requests +from datetime import datetime, timedelta +from typing import Dict, List, Optional + + +class FeishuDocx: + """飞书云文档操作工具类(含图片上传)""" + + BASE_URL = "https://open.feishu.cn/open-apis" + DEFAULT_APP_ID = "cli_a9f29dca82b9dbef" + DEFAULT_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" + + def __init__(self, app_id: str = None, app_secret: str = None): + self.app_id = app_id or os.environ.get("FEISHU_APP_ID") or self.DEFAULT_APP_ID + self.app_secret = app_secret or os.environ.get("FEISHU_APP_SECRET") or self.DEFAULT_APP_SECRET + self._token = None + self._token_expires = None + + @property + def token(self) -> str: + """获取或刷新 access token""" + if self._token and self._token_expires and datetime.now() < self._token_expires: + return self._token + + url = f"{self.BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": self.app_id, + "app_secret": self.app_secret + }) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + self._token = data["tenant_access_token"] + self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return self._token + + @property + def headers(self) -> dict: + """获取请求头""" + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + # ==================== 文档管理 ==================== + + def set_document_permission(self, document_id: str, editable: bool = True) -> bool: + """ + 设置文档权限 + + Args: + document_id: 文档ID + editable: True=组织内可编辑, False=组织内只读 + """ + url = f"{self.BASE_URL}/drive/v1/permissions/{document_id}/public" + payload = { + "external_access_entity": "open", + "security_entity": "anyone_can_view", + "comment_entity": "anyone_can_view", + "share_entity": "anyone", + "link_share_entity": "tenant_editable" if editable else "tenant_readable", + "invite_external": False + } + response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload) + return response.json().get("code") == 0 + + def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict: + """ + 创建文档(自动设置为组织内可编辑) + + Args: + title: 文档标题 + folder_token: 文件夹token (可选) + editable: 是否设置为组织内可编辑 (默认True) + + Returns: + dict: {"document_id": "xxx", "title": "xxx", "url": "xxx"} + """ + url = f"{self.BASE_URL}/docx/v1/documents" + payload = {"title": title} + if folder_token: + payload["folder_token"] = folder_token + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建文档失败: {data}") + + doc = data["data"]["document"] + document_id = doc["document_id"] + + if editable: + self.set_document_permission(document_id, editable=True) + + return { + "document_id": document_id, + "title": doc["title"], + "url": f"https://feishu.cn/docx/{document_id}" + } + + def get_document_blocks(self, document_id: str, page_size: int = 500) -> List[Dict]: + """获取文档所有内容块""" + url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks" + all_blocks = [] + page_token = None + + while True: + params = {"page_size": page_size} + if page_token: + params["page_token"] = page_token + + response = requests.get(url, headers=self.headers, params=params) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取内容块失败: {data}") + + all_blocks.extend(data["data"].get("items", [])) + + if not data["data"].get("has_more"): + break + page_token = data["data"]["page_token"] + + return all_blocks + + # ==================== 内容块操作 ==================== + + def create_blocks(self, document_id: str, blocks: list, parent_id: str = None) -> List[Dict]: + """ + 创建内容块 + + Args: + document_id: 文档ID + blocks: 内容块列表 + parent_id: 父块ID (默认为文档根块) + """ + parent_id = parent_id or document_id + url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + + response = requests.post( + url, + headers=self.headers, + params={"document_revision_id": -1}, + json={"children": blocks} + ) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建内容块失败: {data}") + + return data["data"]["children"] + + # ==================== 图片上传 (三步流程) ==================== + + def create_empty_image_block(self, document_id: str, parent_id: str = None) -> str: + """ + Step 1: 创建空图片块 + + Args: + document_id: 文档ID + parent_id: 父块ID (默认为文档根块) + + Returns: + block_id: 图片块ID + """ + parent_id = parent_id or document_id + url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + + payload = { + "children": [{ + "block_type": 27, + "image": {} + }] + } + + response = requests.post( + url, + headers=self.headers, + params={"document_revision_id": -1}, + json=payload + ) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建图片块失败: {data}") + + return data["data"]["children"][0]["block_id"] + + def upload_image_to_block(self, file_path: str, block_id: str) -> str: + """ + Step 2: 上传图片文件 + + Args: + file_path: 本地图片路径 + block_id: 图片块ID (从 Step 1 获取) + + Returns: + file_token: 图片token + """ + url = f"{self.BASE_URL}/drive/v1/medias/upload_all" + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + # 根据文件扩展名确定 MIME 类型 + ext = os.path.splitext(file_path)[1].lower() + mime_types = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + } + mime_type = mime_types.get(ext, 'image/png') + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, mime_type)} + data = { + 'file_name': file_name, + 'parent_type': 'docx_image', + 'parent_node': block_id, + 'size': str(file_size) + } + response = requests.post( + url, + headers={"Authorization": f"Bearer {self.token}"}, + files=files, + data=data + ) + + result = response.json() + if result.get("code") != 0: + raise Exception(f"上传图片失败: {result}") + + return result["data"]["file_token"] + + def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> bool: + """ + Step 3: 绑定图片到图片块 (关键步骤!) + + Args: + document_id: 文档ID + block_id: 图片块ID + file_token: 图片token (从 Step 2 获取) + + Returns: + bool: 是否成功 + """ + url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + + payload = { + "replace_image": { + "token": file_token + } + } + + response = requests.patch( + url, + headers=self.headers, + params={"document_revision_id": -1}, + json=payload + ) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"绑定图片失败: {data}") + + return True + + def insert_image(self, document_id: str, file_path: str, parent_id: str = None) -> dict: + """ + 插入图片到文档 (封装三步流程) + + Args: + document_id: 文档ID + file_path: 本地图片路径 + parent_id: 父块ID (默认为文档根块) + + Returns: + dict: {"block_id": "xxx", "file_token": "xxx"} + """ + # Step 1: 创建空图片块 + block_id = self.create_empty_image_block(document_id, parent_id) + + # Step 2: 上传图片 + file_token = self.upload_image_to_block(file_path, block_id) + + # Step 3: 绑定图片 + self.bind_image_to_block(document_id, block_id, file_token) + + return {"block_id": block_id, "file_token": file_token} + + def check_image_status(self, document_id: str) -> List[Dict]: + """ + 检查文档中图片块的状态 + + Returns: + List[Dict]: 图片块状态列表 + """ + blocks = self.get_document_blocks(document_id) + images = [] + + for block in blocks: + if block.get("block_type") == 27: + image_data = block.get("image", {}) + images.append({ + "block_id": block.get("block_id"), + "token": image_data.get("token", ""), + "width": image_data.get("width", 0), + "height": image_data.get("height", 0), + "is_valid": bool(image_data.get("token")) + }) + + return images + + # ==================== 内容块构建器 ==================== + + @staticmethod + def heading1(text: str) -> dict: + """一级标题""" + return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}} + + @staticmethod + def heading2(text: str) -> dict: + """二级标题""" + return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}} + + @staticmethod + def heading3(text: str) -> dict: + """三级标题""" + return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}} + + @staticmethod + def text(content: str) -> dict: + """文本段落""" + return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}} + + @staticmethod + def bullet(content: str) -> dict: + """无序列表项""" + return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}} + + @staticmethod + def ordered(content: str) -> dict: + """有序列表项""" + return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}} + + @staticmethod + def code(content: str, language: int = 1) -> dict: + """代码块 (language: 1=JSON, 4=Python, 等)""" + return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}} + + @staticmethod + def divider() -> dict: + """分割线""" + return {"block_type": 22, "divider": {}} + + @staticmethod + def todo(content: str, done: bool = False) -> dict: + """待办事项""" + return {"block_type": 17, "todo": {"elements": [{"text_run": {"content": content}}], "style": {"done": done}}} + + # ==================== 高级功能 ==================== + + def create_meeting_minutes(self, title: str, content: dict) -> dict: + """ + 创建会议纪要 + + Args: + title: 文档标题 + content: { + "summary": "会议摘要", + "points": [{"title": "...", "items": ["..."]}], + "todos": [{"assignee": "...", "task": "..."}] + } + + Returns: + dict: 文档信息 + """ + doc = self.create_document(title) + document_id = doc["document_id"] + + blocks = [] + + if content.get("summary"): + blocks.append(self.heading2("会议摘要")) + blocks.append(self.text(content["summary"])) + blocks.append(self.divider()) + + if content.get("points"): + blocks.append(self.heading2("小结")) + for point in content["points"]: + blocks.append(self.heading3(point["title"])) + for item in point.get("items", []): + blocks.append(self.bullet(item)) + blocks.append(self.divider()) + + if content.get("todos"): + blocks.append(self.heading2("待办事项")) + for todo_item in content["todos"]: + text = f"{todo_item['assignee']}: {todo_item['task']}" + blocks.append(self.todo(text)) + + self.create_blocks(document_id, blocks) + return doc + + +# ==================== 使用示例 ==================== +if __name__ == "__main__": + docx = FeishuDocx() + + # 示例1: 创建文档并插入图片 + print("=== 示例1: 创建带图片的文档 ===") + doc = docx.create_document("图片上传测试文档") + print(f"文档创建成功: {doc['url']}") + + # 如果有测试图片,取消注释以下代码 + # result = docx.insert_image(doc["document_id"], "/path/to/image.png") + # print(f"图片插入成功: {result}") + + # 示例2: 创建会议纪要 + print("\n=== 示例2: 创建会议纪要 ===") + doc = docx.create_meeting_minutes( + "测试会议纪要", + { + "summary": "本次会议讨论了项目进度...", + "points": [{"title": "进度汇报", "items": ["任务A已完成", "任务B进行中"]}], + "todos": [{"assignee": "张三", "task": "完成文档编写"}] + } + ) + print(f"会议纪要已创建: {doc['url']}") + + # 示例3: 检查图片状态 + print("\n=== 示例3: 检查图片状态 ===") + images = docx.check_image_status(doc["document_id"]) + if images: + for img in images: + status = "✅ 有效" if img["is_valid"] else "❌ 空" + print(f"图片: {status}, 尺寸: {img['width']}x{img['height']}") + else: + print("文档中没有图片") diff --git a/plugins/feishu-plugin/migrate_wps.py b/plugins/feishu-plugin/migrate_wps.py new file mode 100644 index 0000000..751485a --- /dev/null +++ b/plugins/feishu-plugin/migrate_wps.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +WPS .dbt 文件迁移到飞书多维表格 +""" + +import pandas as pd +import requests +import json +import re +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional + +# ========== 配置 ========== +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +# 源文件 +SOURCE_FILE = "/Users/donglinlai/Downloads/酷采团购系统优化进度表.dbt.xlsx" + + +class FeishuBitable: + """飞书多维表格操作工具类""" + + def __init__(self): + self._token = None + self._token_expires = None + + @property + def token(self) -> str: + if self._token and self._token_expires and datetime.now() < self._token_expires: + return self._token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + self._token = data["tenant_access_token"] + self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return self._token + + @property + def headers(self): + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + def create_bitable(self, name: str) -> Dict: + """创建多维表格""" + url = f"{BASE_URL}/bitable/v1/apps" + response = requests.post(url, headers=self.headers, json={"name": name}) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建多维表格失败: {data}") + + return data["data"]["app"] + + def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict: + """创建数据表""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables" + payload = { + "table": { + "name": name, + "default_view_name": "默认视图", + "fields": fields + } + } + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建数据表失败: {data}") + + return data["data"] + + def batch_create_records(self, app_token: str, table_id: str, + records: List[Dict], batch_size: int = 100) -> int: + """批量创建记录""" + url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create" + total_created = 0 + + for i in range(0, len(records), batch_size): + batch = records[i:i+batch_size] + payload = {"records": [{"fields": r} for r in batch]} + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + print(f" [WARN] 批次 {i//batch_size + 1} 部分失败: {data.get('msg', '')}") + # 尝试逐条插入 + for record in batch: + try: + single_url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records" + single_resp = requests.post(single_url, headers=self.headers, json={"fields": record}) + if single_resp.json().get("code") == 0: + total_created += 1 + except: + pass + else: + total_created += len(data["data"]["records"]) + + return total_created + + +def analyze_column_type(series: pd.Series, col_name: str) -> Dict: + """分析列的数据类型,返回飞书字段定义""" + col_lower = col_name.lower() + + # 根据列名判断类型 + if any(kw in col_lower for kw in ['日期', '时间', 'date', 'time', '提出时间', '发版日期', '更新时间']): + return {"field_name": col_name, "type": 5, "property": {"date_formatter": "yyyy/MM/dd"}} + + if any(kw in col_lower for kw in ['图片', '附件', '截图', 'image', 'file', 'attachment']): + return {"field_name": col_name, "type": 1} # 作为文本处理 + + if any(kw in col_lower for kw in ['优先级', '状态', '类型', '分类', '终端', '严重程度']): + # 提取唯一值作为选项 + unique_vals = series.dropna().astype(str).unique() + unique_vals = [v for v in unique_vals if v and v != 'nan' and len(v) < 50][:20] + if len(unique_vals) > 0 and len(unique_vals) <= 20: + return { + "field_name": col_name, + "type": 3, # 单选 + "property": { + "options": [{"name": str(v)} for v in unique_vals] + } + } + + if any(kw in col_lower for kw in ['进度', '百分比', '%']): + return {"field_name": col_name, "type": 2} # 数字 + + # 检查是否为数字列 + try: + numeric_vals = pd.to_numeric(series.dropna(), errors='coerce') + if numeric_vals.notna().sum() / max(len(series.dropna()), 1) > 0.8: + return {"field_name": col_name, "type": 2} # 数字 + except: + pass + + # 默认为文本 + return {"field_name": col_name, "type": 1} + + +def clean_value(val: Any, field_type: int) -> Any: + """清理和转换值""" + if pd.isna(val) or val is None: + return None + + if field_type == 5: # 日期 + try: + if isinstance(val, (datetime, pd.Timestamp)): + return int(val.timestamp() * 1000) + elif isinstance(val, str): + dt = pd.to_datetime(val) + return int(dt.timestamp() * 1000) + except: + return None + + if field_type == 2: # 数字 + try: + return float(val) + except: + return None + + if field_type == 3: # 单选 + val_str = str(val).strip() + if val_str and val_str != 'nan': + return val_str + return None + + # 文本类型 + val_str = str(val).strip() + if val_str == 'nan' or not val_str: + return None + + # 限制文本长度 + if len(val_str) > 10000: + val_str = val_str[:10000] + "..." + + return val_str + + +def migrate_sheet(bitable: FeishuBitable, app_token: str, + df: pd.DataFrame, sheet_name: str) -> str: + """迁移单个 Sheet 到数据表""" + print(f"\n{'='*50}") + print(f"迁移 Sheet: 【{sheet_name}】") + print(f"{'='*50}") + + # 清理列名 + df.columns = [str(c).strip() for c in df.columns] + + # 去除完全空的行 + df = df.dropna(how='all') + + print(f" 数据: {len(df)} 行, {len(df.columns)} 列") + + # 分析字段类型 + fields = [] + field_types = {} + for col in df.columns: + if not col or col.startswith('Unnamed'): + continue + field_def = analyze_column_type(df[col], col) + fields.append(field_def) + field_types[col] = field_def["type"] + + print(f" 字段: {len(fields)} 个") + + # 创建数据表 + table_info = bitable.create_table(app_token, sheet_name, fields) + table_id = table_info["table_id"] + print(f" [OK] 数据表创建成功: {table_id}") + + # 准备记录数据 + records = [] + for _, row in df.iterrows(): + record = {} + for col in df.columns: + if not col or col.startswith('Unnamed'): + continue + val = clean_value(row[col], field_types.get(col, 1)) + if val is not None: + record[col] = val + if record: # 只添加非空记录 + records.append(record) + + print(f" 准备导入 {len(records)} 条记录...") + + # 批量创建记录 + if records: + created = bitable.batch_create_records(app_token, table_id, records) + print(f" [OK] 成功导入 {created}/{len(records)} 条记录") + else: + print(f" [INFO] 无有效数据") + + return table_id + + +def main(): + print("\n" + "#" * 60) + print("# WPS 文件迁移到飞书多维表格") + print("#" * 60) + + bitable = FeishuBitable() + + # Step 1: 读取源文件 + print("\n" + "=" * 50) + print("Step 1: 读取源文件") + print("=" * 50) + + xlsx = pd.ExcelFile(SOURCE_FILE) + print(f" 文件: {SOURCE_FILE}") + print(f" Sheet 数量: {len(xlsx.sheet_names)}") + + # Step 2: 创建多维表格 + print("\n" + "=" * 50) + print("Step 2: 创建飞书多维表格") + print("=" * 50) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M") + bitable_name = f"酷采团购系统优化进度表 (迁移 {timestamp})" + + app_info = bitable.create_bitable(bitable_name) + app_token = app_info["app_token"] + print(f" [OK] 多维表格创建成功") + print(f" 名称: {bitable_name}") + print(f" app_token: {app_token}") + + # Step 3: 迁移每个 Sheet + print("\n" + "=" * 50) + print("Step 3: 迁移数据表") + print("=" * 50) + + table_ids = {} + for sheet_name in xlsx.sheet_names: + df = pd.read_excel(xlsx, sheet_name=sheet_name) + table_id = migrate_sheet(bitable, app_token, df, sheet_name) + table_ids[sheet_name] = table_id + + # 完成 + print("\n" + "=" * 60) + print("迁移完成!") + print("=" * 60) + print(f"\n多维表格信息:") + print(f" 名称: {bitable_name}") + print(f" app_token: {app_token}") + print(f"\n数据表:") + for name, tid in table_ids.items(): + print(f" - {name}: {tid}") + print(f"\n访问地址:") + print(f" https://zhiyuncai.feishu.cn/base/{app_token}") + print() + + return app_token, table_ids + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/rebuild_visibility_manual.py b/plugins/feishu-plugin/rebuild_visibility_manual.py new file mode 100644 index 0000000..9484bee --- /dev/null +++ b/plugins/feishu-plugin/rebuild_visibility_manual.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +""" +重建飞书文档:ai-proj 项目可见性手册 +清理现有内容后重新生成 +""" + +import requests +import os +import time +from datetime import datetime, timedelta +from PIL import Image, ImageDraw, ImageFont + +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +# 目标文档 +DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf" + +_token = None +_token_expires = None + + +def get_token(): + global _token, _token_expires + if _token and _token_expires and datetime.now() < _token_expires: + return _token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + _token = data["tenant_access_token"] + _token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return _token + + +def headers(): + return { + "Authorization": f"Bearer {get_token()}", + "Content-Type": "application/json" + } + + +def get_document_blocks(document_id: str): + """获取文档所有块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks" + response = requests.get(url, headers=headers()) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取块失败: {data}") + return data["data"].get("items", []) + + +def delete_block(document_id: str, block_id: str): + """删除块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + response = requests.delete(url, headers=headers(), params={"document_revision_id": -1}) + result = response.json() + return result.get("code") == 0 + + +def clear_document(document_id: str): + """清空文档内容(保留根块)""" + print("正在清空文档...") + blocks = get_document_blocks(document_id) + + # 找出所有子块(排除根块本身) + child_blocks = [b for b in blocks if b.get("block_id") != document_id and b.get("parent_id") == document_id] + + print(f" 发现 {len(child_blocks)} 个顶级子块需要删除") + + # 从后向前删除,避免索引问题 + for block in reversed(child_blocks): + block_id = block.get("block_id") + if delete_block(document_id, block_id): + print(f" 删除: {block_id[:20]}...") + else: + print(f" 删除失败: {block_id[:20]}...") + time.sleep(0.1) # 避免请求过快 + + print(" 文档清空完成") + + +def create_blocks(document_id: str, parent_id: str, blocks: list): + """创建内容块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + payload = {"children": blocks} + response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建块失败: {data}") + return data["data"].get("children", []) + + +def create_image_block(document_id: str, parent_id: str): + """创建空图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + payload = { + "children": [{ + "block_type": 27, + "image": {} + }] + } + response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建图片块失败: {data}") + return data["data"]["children"][0]["block_id"] + + +def upload_image(file_path: str, block_id: str): + """上传图片""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': 'docx_image', + 'parent_node': block_id, + 'size': str(file_size) + } + response = requests.post( + url, + headers={"Authorization": f"Bearer {get_token()}"}, + files=files, + data=data + ) + + result = response.json() + if result.get("code") != 0: + raise Exception(f"上传失败: {result}") + return result["data"]["file_token"] + + +def bind_image(document_id: str, block_id: str, file_token: str): + """绑定图片到图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + payload = {"replace_image": {"token": file_token}} + response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"绑定失败: {data}") + return True + + +def insert_image(document_id: str, file_path: str, description: str = ""): + """插入图片的完整流程""" + print(f" 插入图片: {description}") + + # Step 1: 创建空图片块 + block_id = create_image_block(document_id, document_id) + print(f" block_id: {block_id[:20]}...") + + # Step 2: 上传图片 + file_token = upload_image(file_path, block_id) + print(f" file_token: {file_token[:20]}...") + + # Step 3: 绑定图片 + bind_image(document_id, block_id, file_token) + print(f" 绑定成功!") + + return block_id + + +def generate_visibility_diagram(): + """生成可见性示意图""" + output_path = "/tmp/visibility_diagram.png" + + img = Image.new('RGB', (800, 400), color='#ffffff') + draw = ImageDraw.Draw(img) + + # 尝试加载字体,如果失败则使用默认 + try: + font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24) + font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18) + font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14) + except: + font_large = ImageFont.load_default() + font_medium = ImageFont.load_default() + font_small = ImageFont.load_default() + + # 标题背景 + draw.rectangle([0, 0, 800, 60], fill='#1890ff') + draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large) + + # 私有项目区域 + draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3) + draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium) + draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small) + + # 私有项目说明 + draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small) + + # 私有项目图标 + draw.ellipse([175, 210, 245, 280], fill='#fa8c16') + draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large) + draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small) + draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small) + + # 企业项目区域 + draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3) + draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium) + draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small) + + # 企业项目说明 + draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small) + + # 企业项目图标组 + positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)] + for x, y in positions: + draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff') + draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium) + + draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small) + + # 中间箭头 + draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large) + + img.save(output_path) + print(f"[OK] 生成可见性示意图: {output_path}") + return output_path + + +def generate_ui_screenshot(): + """生成 UI 示意图""" + output_path = "/tmp/visibility_ui.png" + + img = Image.new('RGB', (700, 250), color='#fafafa') + draw = ImageDraw.Draw(img) + + try: + font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16) + font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12) + except: + font_medium = ImageFont.load_default() + font_small = ImageFont.load_default() + + # 标题 + draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium) + + # 私有项目选项(选中状态) + draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2) + draw.ellipse([45, 95, 65, 115], fill='#fa8c16') + draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium) + draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small) + + # 默认标签 + draw.rectangle([250, 85, 310, 110], fill='#52c41a') + draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small) + + # 企业项目选项(未选中状态) + draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1) + draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2) + draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium) + draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small) + + # 底部说明 + draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1) + draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small) + draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small) + + img.save(output_path) + print(f"[OK] 生成 UI 示意图: {output_path}") + return output_path + + +# 文档内容块定义 +def heading1(text): + return { + "block_type": 3, + "heading1": {"elements": [{"text_run": {"content": text}}]} + } + + +def heading2(text): + return { + "block_type": 4, + "heading2": {"elements": [{"text_run": {"content": text}}]} + } + + +def heading3(text): + return { + "block_type": 5, + "heading3": {"elements": [{"text_run": {"content": text}}]} + } + + +def text_block(content): + return { + "block_type": 2, + "text": {"elements": [{"text_run": {"content": content}}]} + } + + +def bullet(content): + return { + "block_type": 12, + "bullet": {"elements": [{"text_run": {"content": content}}]} + } + + +def ordered(content): + return { + "block_type": 13, + "ordered": {"elements": [{"text_run": {"content": content}}]} + } + + +def code_block(content, language=1): + return { + "block_type": 14, + "code": { + "elements": [{"text_run": {"content": content}}], + "language": language + } + } + + +def divider(): + return {"block_type": 22, "divider": {}} + + +def main(): + print("\n" + "#" * 60) + print("# 重建飞书文档:ai-proj 项目可见性手册") + print("#" * 60) + + # Step 1: 生成示意图 + print("\n--- Step 1: 生成示意图 ---") + diagram_path = generate_visibility_diagram() + ui_path = generate_ui_screenshot() + + # Step 2: 清空文档 + print("\n--- Step 2: 清空文档 ---") + clear_document(DOCUMENT_ID) + time.sleep(1) # 等待清空完成 + + # Step 3: 创建手册内容 + print("\n--- Step 3: 创建手册内容 ---") + + # 第一部分:标题和概述 + blocks_part1 = [ + heading1("ai-proj 项目可见性手册"), + text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"), + divider(), + heading2("1. 功能概述"), + text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"), + bullet("私有项目 (private):仅项目创建者和管理员可见"), + bullet("企业项目 (enterprise):企业内所有成员可见"), + text_block("新创建的项目默认为「私有项目」,保护您的隐私。"), + divider(), + heading2("2. 可见性对比"), + text_block("下图展示了两种可见性的区别:"), + ] + create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part1) + print(" 第一部分内容创建完成") + + # 插入可见性示意图 + print("\n--- Step 4: 插入可见性示意图 ---") + insert_image(DOCUMENT_ID, diagram_path, "可见性示意图") + + # 第二部分:设置方法 + print("\n--- Step 5: 添加设置说明 ---") + blocks_part2 = [ + divider(), + heading2("3. 设置项目可见性"), + heading3("3.1 创建项目时设置"), + text_block("在创建项目时,您可以选择项目的可见性:"), + ordered("点击「创建项目」按钮"), + ordered("在弹出的表单中找到「项目可见性」选项"), + ordered("选择「私有项目」或「企业项目」"), + ordered("填写其他信息后点击「确定」"), + text_block("UI 界面示意:"), + ] + create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part2) + print(" 设置说明创建完成") + + # 插入 UI 示意图 + print("\n--- Step 6: 插入 UI 示意图 ---") + insert_image(DOCUMENT_ID, ui_path, "UI 示意图") + + # 第三部分:修改方法和规则 + print("\n--- Step 7: 添加剩余内容 ---") + blocks_part3 = [ + heading3("3.2 修改已有项目"), + ordered("在项目列表中找到目标项目"), + ordered("点击项目卡片上的「编辑」按钮"), + ordered("在编辑页面修改「项目可见性」"), + ordered("点击「保存」"), + divider(), + heading2("4. 可见性规则"), + bullet("私有项目:仅所有者和超级管理员可以查看"), + bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"), + bullet("只有项目所有者或管理员可以修改可见性"), + bullet("设置为「企业项目」需要项目已关联企业"), + divider(), + heading2("5. API 参考"), + text_block("修改项目可见性的 API:"), + code_block('''PATCH /api/v1/projects/:id/visibility + +请求体: +{ + "visibility": "enterprise" +} + +可选值: "private" | "enterprise"'''), + divider(), + heading2("6. 注意事项"), + bullet("新项目默认为私有,请根据需要调整可见性"), + bullet("将私有项目改为企业项目后,企业所有成员都能看到"), + bullet("将企业项目改为私有后,其他成员将无法访问"), + bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"), + divider(), + text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"), + ] + create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part3) + + print("\n" + "=" * 60) + print("文档重建完成!") + print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}") + print("=" * 60) + + # 验证图片 + print("\n--- 验证图片状态 ---") + blocks = get_document_blocks(DOCUMENT_ID) + image_count = 0 + for block in blocks: + if block.get("block_type") == 27: + image_count += 1 + image_data = block.get("image", {}) + token = image_data.get("token", "") + print(f"图片 #{image_count}: token={'有效' if token else '空'} ({token[:20] if token else 'N/A'}...)") + + if image_count == 0: + print("警告:文档中没有图片块") + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/scripts/aiproj_sync.py b/plugins/feishu-plugin/scripts/aiproj_sync.py new file mode 100644 index 0000000..8976535 --- /dev/null +++ b/plugins/feishu-plugin/scripts/aiproj_sync.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +""" +ai-proj 与飞书项目同步脚本 + +功能: +- 初始化:创建飞书根目录、多维表格 +- 全量同步:同步所有项目到飞书 +- 增量同步:只同步新增项目 +- 更新同步:更新现有项目的任务统计等信息 + +使用方法: + python aiproj_sync.py init # 首次初始化 + python aiproj_sync.py sync # 增量同步(推荐日常使用) + python aiproj_sync.py sync-all # 全量同步 + python aiproj_sync.py update # 更新统计信息 +""" + +import os +import sys +import json +import requests +from datetime import datetime +from typing import Optional, Dict, List, Any + +# ============================================ +# 配置 +# ============================================ + +FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef") +FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho") + +# 默认协作者 open_id(邱栋梁@智云采购) +# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者 +DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7" + +AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1" +AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64") + +CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json") + +# ============================================ +# 飞书 API +# ============================================ + +class FeishuAPI: + def __init__(self): + self.token = None + + def get_token(self) -> str: + """获取 tenant_access_token""" + if self.token: + return self.token + + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": FEISHU_APP_ID, + "app_secret": FEISHU_APP_SECRET + }) + data = response.json() + + if data.get("code") == 0: + self.token = data["tenant_access_token"] + return self.token + else: + raise Exception(f"获取飞书 token 失败: {data}") + + def _headers(self, content_type: bool = False) -> Dict: + headers = {"Authorization": f"Bearer {self.get_token()}"} + if content_type: + headers["Content-Type"] = "application/json" + return headers + + def get_root_folder(self) -> str: + """获取云空间根文件夹""" + url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta" + response = requests.get(url, headers=self._headers()) + data = response.json() + + if data.get("code") == 0: + return data["data"]["token"] + raise Exception(f"获取根文件夹失败: {data}") + + def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str: + """创建文件夹""" + url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder" + response = requests.post(url, headers=self._headers(True), json={ + "name": name, + "folder_token": parent_token + }) + data = response.json() + + if data.get("code") == 0: + folder_token = data["data"]["token"] + # 自动添加协作者(应用创建的文件夹默认只有应用能访问) + if add_collaborator and DEFAULT_COLLABORATOR_OPENID: + self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID) + return folder_token + raise Exception(f"创建文件夹失败: {data}") + + def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool: + """添加协作者 + + 应用通过 API 创建的文件/文件夹,所有者是应用本身。 + 组织内用户默认无法访问,需要显式添加为协作者。 + + Args: + file_token: 文件/文件夹 token + file_type: 类型 (folder, doc, sheet, bitable 等) + user_open_id: 用户 open_id + perm: 权限级别 (full_access, edit, view) + """ + url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members" + params = {"type": file_type, "need_notification": "false"} + payload = { + "member_type": "openid", + "member_id": user_open_id, + "perm": perm + } + response = requests.post(url, headers=self._headers(True), params=params, json=payload) + return response.json().get("code") == 0 + + def create_bitable(self, name: str, folder_token: str) -> Dict: + """创建多维表格""" + url = "https://open.feishu.cn/open-apis/bitable/v1/apps" + response = requests.post(url, headers=self._headers(True), json={ + "name": name, + "folder_token": folder_token + }) + data = response.json() + + if data.get("code") == 0: + return data["data"]["app"] + raise Exception(f"创建多维表格失败: {data}") + + def get_bitable_tables(self, app_token: str) -> List[Dict]: + """获取数据表列表""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables" + response = requests.get(url, headers=self._headers()) + data = response.json() + + if data.get("code") == 0: + return data["data"]["items"] + raise Exception(f"获取数据表失败: {data}") + + def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]: + """创建字段""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + response = requests.post(url, headers=self._headers(True), json=field) + data = response.json() + + if data.get("code") == 0: + return data["data"]["field"] + print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}") + return None + + def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool: + """更新字段""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}" + response = requests.put(url, headers=self._headers(True), json=updates) + return response.json().get("code") == 0 + + def get_fields(self, app_token: str, table_id: str) -> List[Dict]: + """获取字段列表""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + response = requests.get(url, headers=self._headers()) + data = response.json() + + if data.get("code") == 0: + return data["data"]["items"] + return [] + + def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]: + """添加记录""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + response = requests.post(url, headers=self._headers(True), json={"fields": fields}) + data = response.json() + + if data.get("code") == 0: + return data["data"]["record"] + print(f" 记录添加失败: {data.get('msg')}") + return None + + def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]: + """获取记录列表""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + params = {"page_size": 500} + if filter_str: + params["filter"] = filter_str + + response = requests.get(url, headers=self._headers(), params=params) + data = response.json() + + if data.get("code") == 0: + return data["data"].get("items", []) + return [] + + def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool: + """更新记录""" + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" + response = requests.put(url, headers=self._headers(True), json={"fields": fields}) + return response.json().get("code") == 0 + +# ============================================ +# ai-proj API +# ============================================ + +class AIProjAPI: + @staticmethod + def get_projects() -> List[Dict]: + """获取项目列表""" + url = f"{AIPROJ_API_BASE}/projects?page_size=100" + headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"} + response = requests.get(url, headers=headers) + data = response.json() + + if data.get("success"): + return data["data"]["data"] + raise Exception(f"获取项目列表失败: {data}") + +# ============================================ +# 配置管理 +# ============================================ + +def load_config() -> Optional[Dict]: + """加载配置""" + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH) as f: + return json.load(f) + return None + +def save_config(config: Dict): + """保存配置""" + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + +# ============================================ +# 同步逻辑 +# ============================================ + +def init(): + """初始化:创建飞书目录结构""" + print("=" * 50) + print("飞书 ai-proj 项目同步 - 初始化") + print("=" * 50) + + if load_config(): + print("\n已存在配置文件,是否重新初始化?(y/N)") + if input().lower() != 'y': + print("取消初始化") + return + + feishu = FeishuAPI() + + # 1. 获取根目录 + print("\n[1/5] 获取云空间根目录...") + space_root = feishu.get_root_folder() + print(f" 根目录: {space_root}") + + # 2. 创建 ai-proj 文件夹 + print("\n[2/5] 创建 ai-proj 根文件夹...") + root_folder = feishu.create_folder("ai-proj", space_root) + print(f" 创建成功: {root_folder}") + + # 3. 创建多维表格 + print("\n[3/5] 创建项目目录多维表格...") + bitable = feishu.create_bitable("项目目录", root_folder) + app_token = bitable["app_token"] + print(f" 创建成功: {app_token}") + + tables = feishu.get_bitable_tables(app_token) + table_id = tables[0]["table_id"] + + # 4. 创建字段 + print("\n[4/5] 创建数据表字段...") + + # 先修改第一列名称 + fields = feishu.get_fields(app_token, table_id) + if fields: + feishu.update_field(app_token, table_id, fields[0]["field_id"], { + "field_name": "项目名称", + "type": fields[0]["type"] + }) + print(" 修改字段: 项目名称") + + # 创建其他字段 + field_definitions = [ + {"field_name": "项目ID", "type": 2}, + {"field_name": "项目编号", "type": 1}, + {"field_name": "飞书文件夹", "type": 15}, + {"field_name": "状态", "type": 3, "property": {"options": [ + {"name": "active", "color": 0}, + {"name": "on_hold", "color": 1}, + {"name": "planning", "color": 2}, + {"name": "completed", "color": 3}, + {"name": "archived", "color": 4} + ]}}, + {"field_name": "优先级", "type": 3, "property": {"options": [ + {"name": "high", "color": 0}, + {"name": "medium", "color": 1}, + {"name": "low", "color": 2} + ]}}, + {"field_name": "所属企业", "type": 1}, + {"field_name": "任务总数", "type": 2}, + {"field_name": "描述", "type": 1}, + {"field_name": "创建时间", "type": 5}, + {"field_name": "最后同步", "type": 5}, + ] + + for field in field_definitions: + if feishu.create_field(app_token, table_id, field): + print(f" 创建字段: {field['field_name']}") + + # 5. 保存配置 + print("\n[5/5] 保存配置...") + config = { + "root_folder_token": root_folder, + "bitable_app_token": app_token, + "bitable_table_id": table_id, + "created_at": datetime.now().isoformat(), + "synced_projects": {} + } + save_config(config) + + print("\n" + "=" * 50) + print("初始化完成!") + print("=" * 50) + print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}") + print(f"多维表格: https://feishu.cn/base/{app_token}") + print(f"\n配置文件: {CONFIG_PATH}") + print("\n运行 'python aiproj_sync.py sync' 同步项目") + +def sync(full: bool = False): + """同步项目""" + config = load_config() + if not config: + print("未找到配置,请先运行 init") + return + + print("=" * 50) + print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步") + print("=" * 50) + + feishu = FeishuAPI() + app_token = config["bitable_app_token"] + table_id = config["bitable_table_id"] + root_folder = config["root_folder_token"] + synced = config.get("synced_projects", {}) + + # 获取 ai-proj 项目 + print("\n获取 ai-proj 项目列表...") + projects = AIProjAPI.get_projects() + print(f" 共 {len(projects)} 个项目") + + # 筛选需要同步的项目 + if full: + to_sync = projects + else: + to_sync = [p for p in projects if str(p["id"]) not in synced] + + if not to_sync: + print("\n没有新项目需要同步") + return + + print(f"\n需要同步 {len(to_sync)} 个项目...") + + success = 0 + for project in to_sync: + project_id = str(project["id"]) + project_name = project["name"] + folder_name = f"{project_name}_{project_id}" + + print(f"\n 处理: {project_name} (ID: {project_id})") + + # 创建文件夹(如果不存在) + folder_token = synced.get(project_id, {}).get("folder_token") + if not folder_token: + try: + folder_token = feishu.create_folder(folder_name, root_folder) + print(f" 创建文件夹: {folder_token}") + except Exception as e: + print(f" 文件夹创建失败: {e}") + folder_token = "" + + folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else "" + + # 添加/更新记录 + record_fields = { + "项目名称": project_name, + "项目ID": int(project_id), + "项目编号": project.get("project_number", ""), + "飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None, + "状态": project.get("status", "active"), + "优先级": project.get("priority", "medium"), + "所属企业": project.get("company_name", ""), + "任务总数": project.get("task_count", 0), + "描述": (project.get("description", "") or "")[:500], + "创建时间": int(datetime.fromisoformat( + project["created_at"].replace("Z", "+00:00") + ).timestamp() * 1000), + "最后同步": int(datetime.now().timestamp() * 1000) + } + + record_id = synced.get(project_id, {}).get("record_id") + if record_id and not full: + # 更新现有记录 + if feishu.update_record(app_token, table_id, record_id, record_fields): + print(f" 更新记录成功") + success += 1 + else: + # 添加新记录 + record = feishu.add_record(app_token, table_id, record_fields) + if record: + record_id = record["record_id"] + print(f" 添加记录成功") + success += 1 + + # 更新同步记录 + synced[project_id] = { + "folder_token": folder_token, + "folder_url": folder_url, + "record_id": record_id, + "synced_at": datetime.now().isoformat() + } + + # 保存配置 + config["synced_projects"] = synced + config["last_sync"] = datetime.now().isoformat() + save_config(config) + + print("\n" + "=" * 50) + print(f"同步完成: {success}/{len(to_sync)}") + print("=" * 50) + print(f"\n多维表格: https://feishu.cn/base/{app_token}") + +def update_stats(): + """更新任务统计信息""" + config = load_config() + if not config: + print("未找到配置,请先运行 init") + return + + print("=" * 50) + print("飞书 ai-proj 项目同步 - 更新统计") + print("=" * 50) + + feishu = FeishuAPI() + app_token = config["bitable_app_token"] + table_id = config["bitable_table_id"] + synced = config.get("synced_projects", {}) + + # 获取最新项目数据 + print("\n获取 ai-proj 项目列表...") + projects = AIProjAPI.get_projects() + project_map = {str(p["id"]): p for p in projects} + + updated = 0 + for project_id, sync_info in synced.items(): + record_id = sync_info.get("record_id") + if not record_id: + continue + + project = project_map.get(project_id) + if not project: + continue + + # 只更新统计字段 + update_fields = { + "任务总数": project.get("task_count", 0), + "状态": project.get("status", "active"), + "最后同步": int(datetime.now().timestamp() * 1000) + } + + if feishu.update_record(app_token, table_id, record_id, update_fields): + print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}") + updated += 1 + + config["last_sync"] = datetime.now().isoformat() + save_config(config) + + print(f"\n更新完成: {updated} 个项目") + +def show_status(): + """显示同步状态""" + config = load_config() + if not config: + print("未初始化,请先运行: python aiproj_sync.py init") + return + + print("=" * 50) + print("ai-proj 飞书同步状态") + print("=" * 50) + print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}") + print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}") + print(f"已同步项目: {len(config.get('synced_projects', {}))}") + print(f"上次同步: {config.get('last_sync', '从未')}") + print(f"配置文件: {CONFIG_PATH}") + +# ============================================ +# 主入口 +# ============================================ + +def main(): + if len(sys.argv) < 2: + print("用法: python aiproj_sync.py ") + print("\n命令:") + print(" init 首次初始化(创建目录和多维表格)") + print(" sync 增量同步(只同步新项目)") + print(" sync-all 全量同步(同步所有项目)") + print(" update 更新统计信息") + print(" status 查看同步状态") + return + + command = sys.argv[1] + + if command == "init": + init() + elif command == "sync": + sync(full=False) + elif command == "sync-all": + sync(full=True) + elif command == "update": + update_stats() + elif command == "status": + show_status() + else: + print(f"未知命令: {command}") + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/skills/SKILL.md b/plugins/feishu-plugin/skills/SKILL.md new file mode 100644 index 0000000..5d179ca --- /dev/null +++ b/plugins/feishu-plugin/skills/SKILL.md @@ -0,0 +1,139 @@ +--- +name: feishu +description: 飞书文档与多维表格操作入口。当用户提到飞书、云文档、多维表格、Bitable 相关任务时自动激活。 +--- + +# 飞书集成 + +## 功能模块 + +| 模块 | 技能 | 说明 | +|------|------|------| +| 云文档 | `feishu-docx` | 创建、编辑云文档,会议纪要 | +| 多维表格 | `feishu-bitable` | 记录增删改查,数据同步 | +| 任务 | 本技能 | 创建待办任务 | + +## 环境配置 + +```bash +# ~/.zshrc(凭证唯一配置位置) +export FEISHU_APP_ID="cli_a9f29dca82b9dbef" +export FEISHU_APP_SECRET="<从飞书开放平台获取>" +``` + +**权限要求**: +- 云文档:`docx:document`, `drive:drive` +- 多维表格:`bitable:app` +- 任务:`task:task:write` + +## Access Token + +```python +import os, requests + +def get_tenant_access_token(): + """获取飞书 tenant_access_token""" + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": os.environ["FEISHU_APP_ID"], + "app_secret": os.environ["FEISHU_APP_SECRET"] + }) + data = response.json() + if data.get("code") == 0: + return data["tenant_access_token"] + raise Exception(f"获取 token 失败: {data}") +``` + +## 默认存储位置 + +| 文件夹 | folder_token | +|--------|-------------| +| ai-proj 根目录 | `RTLKf247ClQQDyd5IjxcTOVQnxd` | +| 01运营 (默认) | `C80gfkRnzlonQ5d4AhOcOACDnNg` | + +## URL 结构 + +``` +云文档: https://xxx.feishu.cn/docx/DoxcXXXXXX + └── document_id + +多维表格: https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX + └── app_token └── table_id +``` + +## 飞书任务 + +```python +def create_task(summary: str, due_time: int = None): + """创建飞书任务""" + url = "https://open.feishu.cn/open-apis/task/v2/tasks" + token = get_tenant_access_token() + payload = {"summary": summary} + if due_time: + payload["due"] = {"timestamp": str(due_time), "is_all_day": False} + response = requests.post(url, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json=payload) + data = response.json() + if data.get("code") == 0: + return data["data"]["task"] + raise Exception(f"创建任务失败: {data}") +``` + +## 工具类 + +完整工具类见: +- `~/.claude/skills/feishu/feishu_bitable.py` - 多维表格 +- `~/.claude/skills/feishu/feishu_docx.py` - 云文档 + +## Incoming Webhook(群机器人通知卡片) + +**Webhook 地址**:存储在 `~/.config/devops/credentials.env` → `FEISHU_DEPLOY_WEBHOOK` + +**⚠️ 关键注意事项**: +- ❌ **禁用 schema 2.0**:`"schema": "2.0"` 会返回 ErrCode 11246,必须用 legacy 格式 +- ✅ **legacy 卡片格式**(无 schema 字段)才能正常发送 +- ❌ **按钮 URL 禁止指向列表页**:必须带具体资源 ID(如 `/requirements/864`,不能是 `/requirements`) +- ✅ **保存到文件再 curl**:包含中文的 JSON 直接用 `'...'` 传参会报 "blank argument" 错误 + +**正确的卡片发送示例**: +```bash +cat > /tmp/feishu_card.json << 'EOF' +{ + "msg_type": "interactive", + "card": { + "header": { + "title": {"tag": "plain_text", "content": "通知标题"}, + "template": "blue" + }, + "elements": [ + { + "tag": "div", + "text": {"tag": "lark_md", "content": "**内容**:描述文字"} + }, + { + "tag": "action", + "actions": [{ + "tag": "button", + "text": {"tag": "plain_text", "content": "查看详情"}, + "type": "primary", + "url": "https://ai.pipexerp.com/requirements/864" + }] + } + ] + } +} +EOF + +curl -s -X POST \ + "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" \ + -H "Content-Type: application/json" \ + -d @/tmp/feishu_card.json +``` + +**header template 颜色**:`blue`(待审批)/ `green`(通过)/ `red`(驳回)/ `wathet`(信息) + +## 相关技能 + +- `feishu-docx` - 云文档详细操作 +- `feishu-bitable` - 多维表格详细操作 diff --git a/plugins/feishu-plugin/test_docx_image.py b/plugins/feishu-plugin/test_docx_image.py new file mode 100644 index 0000000..c7e6695 --- /dev/null +++ b/plugins/feishu-plugin/test_docx_image.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +""" +测试飞书云文档图片上传 +""" + +import requests +import os +from datetime import datetime, timedelta +from PIL import Image, ImageDraw + +# ========== 配置 ========== +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + + +class FeishuDocx: + """飞书云文档操作工具类""" + + def __init__(self): + self._token = None + self._token_expires = None + + @property + def token(self) -> str: + if self._token and self._token_expires and datetime.now() < self._token_expires: + return self._token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + self._token = data["tenant_access_token"] + self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + print(f"[OK] Token 获取成功") + return self._token + + @property + def headers(self): + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + def set_document_permission(self, document_id: str, editable: bool = True) -> bool: + """设置文档权限为组织内可编辑""" + url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public" + + payload = { + "external_access_entity": "open", + "security_entity": "anyone_can_view", + "comment_entity": "anyone_can_view", + "share_entity": "anyone", + "link_share_entity": "tenant_editable" if editable else "tenant_readable", + "invite_external": False + } + + response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload) + result = response.json() + if result.get("code") == 0: + print(f"[OK] 权限设置成功: 组织内可编辑") + return True + else: + print(f"[WARN] 权限设置失败: {result.get('msg')}") + return False + + def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict: + """创建云文档(自动设置为组织内可编辑)""" + url = f"{BASE_URL}/docx/v1/documents" + payload = {"title": title} + if folder_token: + payload["folder_token"] = folder_token + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建文档失败: {data}") + + doc = data["data"]["document"] + print(f"[OK] 文档创建成功: {doc['document_id']}") + + # 自动设置权限 + if editable: + self.set_document_permission(doc['document_id'], editable=True) + + return doc + + def create_empty_image_block(self, document_id: str, index: int = -1) -> str: + """ + 创建空的图片块,返回 block_id + + 正确流程第一步:先创建空图片块 + """ + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children" + + # 创建空的图片块 + image_block = { + "block_type": 27, + "image": {} # 空的图片块 + } + + payload = {"children": [image_block]} + if index >= 0: + payload["index"] = index + + response = requests.post(url, headers=self.headers, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"创建图片块失败: {data}") + + # 获取创建的图片块 ID + children = data["data"].get("children", []) + if not children: + raise Exception("创建图片块失败: 没有返回 children") + + block_id = children[0].get("block_id") + print(f"[OK] 空图片块创建成功, block_id: {block_id}") + return block_id + + def upload_image_to_block(self, file_path: str, block_id: str) -> str: + """ + 上传图片到指定的图片块 + + 正确流程第二步:将图片上传并绑定到已创建的图片块 + """ + url = f"{BASE_URL}/drive/v1/medias/upload_all" + + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + headers = {"Authorization": f"Bearer {self.token}"} + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': 'docx_image', # 云文档图片 + 'parent_node': block_id, # 关键: 使用图片块的 block_id + 'size': str(file_size) + } + + print(f"[INFO] 上传图片到 block_id={block_id}") + response = requests.post(url, headers=headers, files=files, data=data) + + result = response.json() + print(f"[DEBUG] 上传响应: code={result.get('code')}, msg={result.get('msg')}") + + if result.get("code") != 0: + raise Exception(f"上传图片失败: {result}") + + file_token = result["data"]["file_token"] + print(f"[OK] 图片上传成功, file_token: {file_token}") + return file_token + + def _upload_media(self, file_path: str, parent_type: str, parent_node: str = '') -> str: + """使用 media API 上传""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + headers = {"Authorization": f"Bearer {self.token}"} + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': parent_type, + 'parent_node': parent_node, + 'size': str(file_size) + } + + response = requests.post(url, headers=headers, files=files, data=data) + + result = response.json() + print(f"[DEBUG] media 响应 ({parent_type}): code={result.get('code')}, msg={result.get('msg')}") + + if result.get("code") == 0: + return result["data"]["file_token"] + return None + + def _upload_to_drive(self, file_path: str) -> str: + """上传到云空间根目录,然后获取 file_token""" + # 先获取根文件夹 token + url = f"{BASE_URL}/drive/explorer/v2/root_folder/meta" + headers = {"Authorization": f"Bearer {self.token}"} + + response = requests.get(url, headers=headers) + result = response.json() + print(f"[DEBUG] 根目录响应: {result}") + + if result.get("code") != 0: + return None + + root_token = result["data"]["token"] + + # 上传文件 + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + upload_url = f"{BASE_URL}/drive/v1/medias/upload_all" + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': 'explorer', + 'parent_node': root_token, + 'size': str(file_size) + } + + response = requests.post(upload_url, headers=headers, files=files, data=data) + + result = response.json() + print(f"[DEBUG] drive 上传响应: code={result.get('code')}, msg={result.get('msg')}") + + if result.get("code") == 0: + return result["data"]["file_token"] + return None + + def _upload_im_image(self, file_path: str) -> str: + """使用消息图片 API 上传""" + url = f"{BASE_URL}/im/v1/images" + + headers = {"Authorization": f"Bearer {self.token}"} + + with open(file_path, 'rb') as f: + files = {'image': f} + data = {'image_type': 'message'} + + response = requests.post(url, headers=headers, files=files, data=data) + + result = response.json() + print(f"[DEBUG] im 图片响应: code={result.get('code')}, msg={result.get('msg')}") + + if result.get("code") == 0: + return result["data"]["image_key"] + return None + + def _try_upload_with_type(self, file_path: str, parent_type: str) -> str: + """尝试不同的 parent_type 上传""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + headers = {"Authorization": f"Bearer {self.token}"} + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': parent_type, + 'parent_node': '', + 'size': str(file_size) + } + + print(f"[INFO] 尝试 parent_type={parent_type}") + response = requests.post(url, headers=headers, files=files, data=data) + + result = response.json() + print(f"[DEBUG] 响应: {result}") + + if result.get("code") != 0: + raise Exception(f"上传失败 ({parent_type}): {result}") + + return result["data"]["file_token"] + + def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> dict: + """ + 绑定图片到图片块 (关键的第三步!) + + 使用 PATCH 请求和 replace_image 字段将图片绑定到已创建的图片块 + """ + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + + params = {"document_revision_id": -1} + + payload = { + "replace_image": { + "token": file_token + } + } + + print(f"[INFO] 绑定图片到块: block_id={block_id}") + response = requests.patch(url, headers=self.headers, params=params, json=payload) + data = response.json() + + if data.get("code") != 0: + raise Exception(f"绑定图片失败: {data}") + + print(f"[OK] 图片绑定成功") + return data["data"] + + +def generate_test_image(output_path: str): + """生成测试图片""" + img = Image.new('RGB', (400, 300), color='#4a90d9') + draw = ImageDraw.Draw(img) + draw.rectangle([20, 20, 380, 280], fill='#f0f4f8', outline='#2563eb', width=2) + draw.text((200, 150), "Test Image", fill='#1e3a5f', anchor='mm') + draw.text((200, 200), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill='#6b7280', anchor='mm') + img.save(output_path) + print(f"[OK] 测试图片生成: {output_path}") + return output_path + + +def main(): + print("\n" + "#" * 60) + print("# 飞书云文档图片上传测试") + print("#" * 60) + + docx = FeishuDocx() + + # Step 1: 生成测试图片 + print("\n" + "=" * 50) + print("Step 1: 生成测试图片") + print("=" * 50) + + test_image = "/tmp/feishu_docx_test_image.png" + generate_test_image(test_image) + + # Step 2: 创建测试文档 + print("\n" + "=" * 50) + print("Step 2: 创建测试文档") + print("=" * 50) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + doc = docx.create_document(f"图片上传测试 - {timestamp}") + document_id = doc["document_id"] + + # Step 3: 创建空的图片块 + print("\n" + "=" * 50) + print("Step 3: 创建空的图片块") + print("=" * 50) + + try: + block_id = docx.create_empty_image_block(document_id) + except Exception as e: + print(f"[ERROR] 创建图片块失败: {e}") + return + + # Step 4: 上传图片 + print("\n" + "=" * 50) + print("Step 4: 上传图片") + print("=" * 50) + + try: + file_token = docx.upload_image_to_block(test_image, block_id) + except Exception as e: + print(f"[ERROR] 上传图片失败: {e}") + return + + # Step 5: 绑定图片到图片块 (关键步骤!) + print("\n" + "=" * 50) + print("Step 5: 绑定图片到图片块") + print("=" * 50) + + try: + docx.bind_image_to_block(document_id, block_id, file_token) + except Exception as e: + print(f"[ERROR] 绑定图片失败: {e}") + return + + # 完成 + print("\n" + "=" * 60) + print("测试完成!") + print("=" * 60) + print(f"\n文档地址:") + print(f" https://feishu.cn/docx/{document_id}") + print() + + +def check_permissions(docx): + """检查应用权限""" + # 获取应用信息 + url = f"{BASE_URL}/application/v6/applications/underauditlist" + headers = {"Authorization": f"Bearer {docx.token}"} + + response = requests.get(url, headers=headers) + print(f"[DEBUG] 权限检查响应: {response.json()}") + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/update_visibility_manual.py b/plugins/feishu-plugin/update_visibility_manual.py new file mode 100644 index 0000000..70fb647 --- /dev/null +++ b/plugins/feishu-plugin/update_visibility_manual.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +更新飞书文档:ai-proj 项目可见性手册 +""" + +import requests +import os +from datetime import datetime, timedelta +from PIL import Image, ImageDraw, ImageFont + +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +# 目标文档 +DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf" + +_token = None +_token_expires = None + + +def get_token(): + global _token, _token_expires + if _token and _token_expires and datetime.now() < _token_expires: + return _token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + _token = data["tenant_access_token"] + _token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return _token + + +def headers(): + return { + "Authorization": f"Bearer {get_token()}", + "Content-Type": "application/json" + } + + +def get_document_blocks(document_id: str): + """获取文档所有块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks" + response = requests.get(url, headers=headers()) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取块失败: {data}") + return data["data"].get("items", []) + + +def delete_block(document_id: str, block_id: str): + """删除块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + response = requests.delete(url, headers=headers(), params={"document_revision_id": -1}) + return response.json().get("code") == 0 + + +def create_blocks(document_id: str, parent_id: str, blocks: list): + """创建内容块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + payload = {"children": blocks} + response = requests.post(url, headers=headers(), json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建块失败: {data}") + return data["data"].get("children", []) + + +def create_image_block(document_id: str, parent_id: str): + """创建空图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children" + payload = { + "children": [{ + "block_type": 27, + "image": {} + }] + } + response = requests.post(url, headers=headers(), json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建图片块失败: {data}") + return data["data"]["children"][0]["block_id"] + + +def upload_image(file_path: str, block_id: str): + """上传图片""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, 'image/png')} + data = { + 'file_name': file_name, + 'parent_type': 'docx_image', + 'parent_node': block_id, + 'size': str(file_size) + } + response = requests.post( + url, + headers={"Authorization": f"Bearer {get_token()}"}, + files=files, + data=data + ) + + result = response.json() + if result.get("code") != 0: + raise Exception(f"上传失败: {result}") + return result["data"]["file_token"] + + +def bind_image(document_id: str, block_id: str, file_token: str): + """绑定图片到图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + payload = {"replace_image": {"token": file_token}} + response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"绑定失败: {data}") + return True + + +def generate_visibility_diagram(): + """生成可见性示意图""" + output_path = "/tmp/visibility_diagram.png" + + img = Image.new('RGB', (800, 400), color='#ffffff') + draw = ImageDraw.Draw(img) + + # 标题 + draw.rectangle([0, 0, 800, 50], fill='#1890ff') + draw.text((400, 25), "项目可见性示意图", fill='white', anchor='mm') + + # 私有项目 + draw.rectangle([50, 80, 370, 350], fill='#fff7e6', outline='#fa8c16', width=2) + draw.text((210, 100), "🔒 私有项目 (private)", fill='#fa8c16', anchor='mm') + draw.text((210, 140), "仅创建者可见", fill='#666666', anchor='mm') + + # 私有项目内的人员图标 + draw.ellipse([180, 180, 240, 240], fill='#fa8c16') + draw.text((210, 210), "👤", fill='white', anchor='mm') + draw.text((210, 270), "项目所有者", fill='#333333', anchor='mm') + draw.text((210, 300), "管理员", fill='#999999', anchor='mm') + + # 企业项目 + draw.rectangle([430, 80, 750, 350], fill='#e6f7ff', outline='#1890ff', width=2) + draw.text((590, 100), "👥 企业项目 (enterprise)", fill='#1890ff', anchor='mm') + draw.text((590, 140), "企业内所有成员可见", fill='#666666', anchor='mm') + + # 企业项目内的人员图标组 + positions = [(520, 190), (590, 190), (660, 190), (555, 250), (625, 250)] + for x, y in positions: + draw.ellipse([x-20, y-20, x+20, y+20], fill='#1890ff') + draw.text((x, y), "👤", fill='white', anchor='mm') + + draw.text((590, 300), "企业所有成员", fill='#333333', anchor='mm') + draw.text((590, 330), "管理员", fill='#999999', anchor='mm') + + # 箭头 + draw.text((400, 200), "→", fill='#333333', anchor='mm') + + img.save(output_path) + print(f"[OK] 生成可见性示意图: {output_path}") + return output_path + + +def generate_ui_screenshot(): + """生成 UI 示意图""" + output_path = "/tmp/visibility_ui.png" + + img = Image.new('RGB', (600, 200), color='#fafafa') + draw = ImageDraw.Draw(img) + + # 标题 + draw.text((20, 20), "项目可见性选择", fill='#333333') + + # Radio Group 模拟 + # 私有项目选项 + draw.rectangle([20, 60, 280, 100], fill='#fff7e6', outline='#fa8c16', width=2) + draw.ellipse([30, 70, 50, 90], fill='#fa8c16') + draw.text((60, 80), "🔒 私有项目", fill='#fa8c16', anchor='lm') + + # 企业项目选项 + draw.rectangle([300, 60, 560, 100], fill='#ffffff', outline='#d9d9d9', width=1) + draw.ellipse([310, 70, 330, 90], outline='#d9d9d9', width=1) + draw.text((340, 80), "👥 企业项目", fill='#666666', anchor='lm') + + # 说明文字 + draw.text((20, 130), "私有项目仅创建者可见,企业项目对企业内所有成员可见", fill='#999999') + + # 默认标签 + draw.rectangle([200, 65, 270, 85], fill='#52c41a') + draw.text((235, 75), "默认", fill='white', anchor='mm') + + img.save(output_path) + print(f"[OK] 生成 UI 示意图: {output_path}") + return output_path + + +# 文档内容块定义 +def heading1(text): + return { + "block_type": 3, + "heading1": {"elements": [{"text_run": {"content": text}}]} + } + + +def heading2(text): + return { + "block_type": 4, + "heading2": {"elements": [{"text_run": {"content": text}}]} + } + + +def heading3(text): + return { + "block_type": 5, + "heading3": {"elements": [{"text_run": {"content": text}}]} + } + + +def text_block(content): + return { + "block_type": 2, + "text": {"elements": [{"text_run": {"content": content}}]} + } + + +def bullet(content): + return { + "block_type": 12, + "bullet": {"elements": [{"text_run": {"content": content}}]} + } + + +def ordered(content): + return { + "block_type": 13, + "ordered": {"elements": [{"text_run": {"content": content}}]} + } + + +def code_block(content, language="json"): + return { + "block_type": 14, + "code": { + "elements": [{"text_run": {"content": content}}], + "language": 1 if language == "json" else 0 + } + } + + +def divider(): + return {"block_type": 22, "divider": {}} + + +def main(): + print("\n" + "#" * 60) + print("# 更新飞书文档:ai-proj 项目可见性手册") + print("#" * 60) + + # Step 1: 生成示意图 + print("\n--- Step 1: 生成示意图 ---") + diagram_path = generate_visibility_diagram() + ui_path = generate_ui_screenshot() + + # Step 2: 直接追加内容(不删除现有内容) + print("\n--- Step 2: 追加手册内容 ---") + print("\n--- Step 3: 创建手册内容 ---") + + # 定义手册内容 + manual_blocks = [ + heading1("ai-proj 项目可见性手册"), + text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"), + divider(), + + heading2("1. 功能概述"), + text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"), + bullet("私有项目 (private):仅项目创建者和管理员可见"), + bullet("企业项目 (enterprise):企业内所有成员可见"), + text_block("新创建的项目默认为「私有项目」,保护您的隐私。"), + divider(), + + heading2("2. 可见性对比"), + ] + + # 创建第一批内容块 + created = create_blocks(DOCUMENT_ID, DOCUMENT_ID, manual_blocks) + print(f" 创建了 {len(created)} 个内容块") + + # Step 4: 插入可见性示意图 + print("\n--- Step 4: 插入可见性示意图 ---") + img_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID) + print(f" 图片块: {img_block_id}") + file_token = upload_image(diagram_path, img_block_id) + print(f" file_token: {file_token}") + bind_image(DOCUMENT_ID, img_block_id, file_token) + print(" 图片绑定成功!") + + # Step 5: 继续添加内容 + print("\n--- Step 5: 添加更多内容 ---") + more_blocks = [ + divider(), + heading2("3. 设置项目可见性"), + + heading3("3.1 创建项目时设置"), + ordered("点击「创建项目」按钮"), + ordered("在弹出的表单中找到「项目可见性」选项"), + ordered("选择「私有项目」或「企业项目」"), + ordered("填写其他信息后点击「确定」"), + ] + create_blocks(DOCUMENT_ID, DOCUMENT_ID, more_blocks) + + # Step 6: 插入 UI 示意图 + print("\n--- Step 6: 插入 UI 示意图 ---") + ui_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID) + ui_file_token = upload_image(ui_path, ui_block_id) + bind_image(DOCUMENT_ID, ui_block_id, ui_file_token) + print(" UI 示意图上传成功!") + + # Step 7: 添加剩余内容 + print("\n--- Step 7: 添加剩余内容 ---") + final_blocks = [ + heading3("3.2 修改已有项目"), + ordered("在项目列表中找到目标项目"), + ordered("点击项目卡片上的「编辑」按钮"), + ordered("在编辑页面修改「项目可见性」"), + ordered("点击「保存」"), + divider(), + + heading2("4. 可见性规则"), + bullet("私有项目:仅所有者和超级管理员可以查看"), + bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"), + bullet("只有项目所有者或管理员可以修改可见性"), + bullet("设置为「企业项目」需要项目已关联企业"), + divider(), + + heading2("5. API 参考"), + text_block("修改项目可见性的 API:"), + code_block('''PATCH /api/v1/projects/:id/visibility + +请求体: +{ + "visibility": "enterprise" +} + +可选值: "private" | "enterprise"'''), + divider(), + + heading2("6. 注意事项"), + bullet("新项目默认为私有,请根据需要调整可见性"), + bullet("将私有项目改为企业项目后,企业所有成员都能看到"), + bullet("将企业项目改为私有后,其他成员将无法访问"), + bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"), + divider(), + + text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"), + ] + create_blocks(DOCUMENT_ID, DOCUMENT_ID, final_blocks) + + print("\n" + "=" * 60) + print("文档更新完成!") + print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/upload_user_image.py b/plugins/feishu-plugin/upload_user_image.py new file mode 100644 index 0000000..28dfd49 --- /dev/null +++ b/plugins/feishu-plugin/upload_user_image.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +上传用户指定的图片到飞书云文档 +""" + +import requests +import os +from datetime import datetime, timedelta + +ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef" +ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho" +BASE_URL = "https://open.feishu.cn/open-apis" + +# 用户指定的图片 +IMAGE_PATH = "/Users/donglinlai/Downloads/u274.png" + +_token = None +_token_expires = None + + +def get_token(): + global _token, _token_expires + if _token and _token_expires and datetime.now() < _token_expires: + return _token + + url = f"{BASE_URL}/auth/v3/tenant_access_token/internal" + response = requests.post(url, json={ + "app_id": ZHIYUN_APP_ID, + "app_secret": ZHIYUN_APP_SECRET + }) + data = response.json() + if data.get("code") != 0: + raise Exception(f"获取 token 失败: {data}") + + _token = data["tenant_access_token"] + _token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60) + return _token + + +def headers(): + return { + "Authorization": f"Bearer {get_token()}", + "Content-Type": "application/json" + } + + +def set_document_permission(document_id: str, editable: bool = True): + """ + 设置文档权限 + + Args: + document_id: 文档ID + editable: True=组织内可编辑, False=组织内只读 + """ + url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public" + + payload = { + "external_access_entity": "open", + "security_entity": "anyone_can_view", + "comment_entity": "anyone_can_view", + "share_entity": "anyone", + "link_share_entity": "tenant_editable" if editable else "tenant_readable", + "invite_external": False + } + + response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload) + data = response.json() + + if data.get("code") == 0: + print(f" 权限设置成功: {'组织内可编辑' if editable else '组织内只读'}") + return True + else: + print(f" [WARN] 权限设置失败: {data.get('msg')}") + return False + + +def create_document(title: str, editable: bool = True): + """创建文档并设置权限""" + url = f"{BASE_URL}/docx/v1/documents" + response = requests.post(url, headers=headers(), json={"title": title}) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建文档失败: {data}") + + doc = data["data"]["document"] + document_id = doc["document_id"] + + # 自动设置权限 + if editable: + set_document_permission(document_id, editable=True) + + return document_id + + +def create_image_block(document_id: str): + """创建空图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children" + payload = { + "children": [{ + "block_type": 27, + "image": {} + }] + } + response = requests.post(url, headers=headers(), json=payload) + data = response.json() + if data.get("code") != 0: + raise Exception(f"创建图片块失败: {data}") + return data["data"]["children"][0]["block_id"] + + +def upload_image(file_path: str, block_id: str): + """上传图片""" + url = f"{BASE_URL}/drive/v1/medias/upload_all" + + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + # 根据扩展名设置 MIME 类型 + ext = file_name.lower().split('.')[-1] + mime_types = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp' + } + mime_type = mime_types.get(ext, 'image/png') + + with open(file_path, 'rb') as f: + files = {'file': (file_name, f, mime_type)} + data = { + 'file_name': file_name, + 'parent_type': 'docx_image', + 'parent_node': block_id, + 'size': str(file_size) + } + + response = requests.post( + url, + headers={"Authorization": f"Bearer {get_token()}"}, + files=files, + data=data + ) + + result = response.json() + if result.get("code") != 0: + raise Exception(f"上传失败: {result}") + + return result["data"]["file_token"] + + +def bind_image(document_id: str, block_id: str, file_token: str): + """绑定图片到图片块""" + url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}" + payload = { + "replace_image": { + "token": file_token + } + } + response = requests.patch( + url, + headers=headers(), + params={"document_revision_id": -1}, + json=payload + ) + data = response.json() + if data.get("code") != 0: + raise Exception(f"绑定失败: {data}") + return True + + +def main(): + print(f"\n上传图片: {IMAGE_PATH}") + print("=" * 60) + + # 检查文件 + if not os.path.exists(IMAGE_PATH): + print(f"[ERROR] 文件不存在: {IMAGE_PATH}") + return + + file_size = os.path.getsize(IMAGE_PATH) + print(f"文件大小: {file_size / 1024:.1f} KB") + + # Step 1: 创建文档 + print("\n[1/4] 创建飞书文档...") + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") + doc_id = create_document(f"火把图片 - {timestamp}") + print(f" 文档ID: {doc_id}") + + # Step 2: 创建图片块 + print("[2/4] 创建图片块...") + block_id = create_image_block(doc_id) + print(f" 块ID: {block_id}") + + # Step 3: 上传图片 + print("[3/4] 上传图片...") + file_token = upload_image(IMAGE_PATH, block_id) + print(f" file_token: {file_token}") + + # Step 4: 绑定图片 + print("[4/4] 绑定图片...") + bind_image(doc_id, block_id, file_token) + print(" 绑定成功!") + + print("\n" + "=" * 60) + print("上传完成!") + print(f"\n文档地址: https://feishu.cn/docx/{doc_id}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/plugins/feishu-plugin/users.json b/plugins/feishu-plugin/users.json new file mode 100644 index 0000000..198f8b2 --- /dev/null +++ b/plugins/feishu-plugin/users.json @@ -0,0 +1,10 @@ +{ + "吴薇儿": { + "email": "wuweier@zhiyuncai.com", + "open_id": "ou_1d5cdfee78cbe6f8acc0751fff00ed09" + }, + "宋佳香": { + "email": "songjiaxiang@zhiyuncai.com", + "open_id": "e6e72eb8" + } +} \ No newline at end of file diff --git a/plugins/finance-plugin/.claude-plugin/plugin.json b/plugins/finance-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..4e75bfd --- /dev/null +++ b/plugins/finance-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "finance-plugin", + "description": "Plugin for finance", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/finance-plugin/skills/SKILL.md b/plugins/finance-plugin/skills/SKILL.md new file mode 100644 index 0000000..2a1d91c --- /dev/null +++ b/plugins/finance-plugin/skills/SKILL.md @@ -0,0 +1,948 @@ +--- +name: finance +description: 财务对账技能。支持银行对账、应收对账、应付对账、内部往来对账等。通过自然语言实现账单导入、差异分析、对账报告生成。当用户提到对账、银行流水、应收应付、账单核对、财务核销相关任务时自动激活。 +--- + +# 财务对账技能 + +## 功能概述 + +- **银行对账**: 银行流水 vs 系统收付款记录匹配 +- **应收对账**: 客户对账单生成与核对 +- **应付对账**: 供应商对账单核对 +- **内部对账**: 公司间/部门间往来核对 +- **差异分析**: 自动识别未达账项、差异原因 +- **对账报告**: 生成标准化对账报告 +- **扫描单据OCR**: PDF/图片扫描件识别,提取订单号与对账单匹配(适用于诉讼证据核对) + +--- + +## 对账类型 + +### 1. 银行对账 + +| 对账项 | 说明 | +|--------|------| +| 银行已收/企业未收 | 银行已入账,系统未记录 | +| 企业已收/银行未收 | 系统已记录,银行未入账 | +| 银行已付/企业未付 | 银行已扣款,系统未记录 | +| 企业已付/银行未付 | 系统已记录,银行未扣款 | + +### 2. 应收对账 + +- 客户欠款余额核对 +- 发票与回款匹配 +- 账龄分析 + +### 3. 应付对账 + +- 供应商应付余额核对 +- 采购入库与付款匹配 +- 预付款冲销 + +--- + +## 数据格式要求 + +### 银行流水格式 + +```csv +交易日期,交易类型,摘要,金额,余额,对方账户,对方名称,交易流水号 +2026-01-15,收入,货款,50000.00,150000.00,622848******1234,深圳XX公司,TXN20260115001 +2026-01-16,支出,采购款,-30000.00,120000.00,622848******5678,广州YY公司,TXN20260116001 +``` + +### 系统收付款格式 + +```csv +单据日期,单据类型,单据编号,金额,客户/供应商,备注,关联流水号 +2026-01-15,收款单,SK20260115001,50000.00,深圳XX公司,货款,TXN20260115001 +2026-01-16,付款单,FK20260116001,30000.00,广州YY公司,采购款, +``` + +--- + +## 自然语言操作示例 + +### 银行对账 + +| 用户说 | 执行操作 | +|--------|----------| +| "导入银行流水" | 解析银行导出的 Excel/CSV | +| "和系统记录对账" | 自动匹配流水号/金额/日期 | +| "找出未达账项" | 识别双方差异记录 | +| "生成银行余额调节表" | 输出标准调节表 | + +### 应收对账 + +| 用户说 | 执行操作 | +|--------|----------| +| "生成客户对账单" | 按客户汇总应收明细 | +| "核对XX公司的应收款" | 单客户对账 | +| "应收账龄分析" | 按账龄分段统计 | +| "哪些发票还没收到款" | 查询未核销发票 | + +### 应付对账 + +| 用户说 | 执行操作 | +|--------|----------| +| "核对供应商对账单" | 导入并匹配供应商账单 | +| "本月应付汇总" | 按供应商统计应付 | +| "哪些采购单还没付款" | 查询未付款采购单 | + +### 扫描单据OCR对账 + +| 用户说 | 执行操作 | +|--------|----------| +| "识别扫描件与对账单匹配" | OCR 提取 PDF 订单号并与 Excel 对账 | +| "承运商起诉了,核对证据" | 诉讼证据对账分析 | +| "PDF发票识别" | OCR 提取发票信息 | +| "找出对账单没有的订单" | 差异订单识别 | + +--- + +## Python 代码模板 + +### 银行对账核心逻辑 + +```python +import pandas as pd +from datetime import datetime + +def load_bank_statement(file_path: str) -> pd.DataFrame: + """加载银行流水""" + df = pd.read_excel(file_path) if file_path.endswith('.xlsx') else pd.read_csv(file_path) + + # 标准化列名 + column_mapping = { + '交易日期': 'date', + '金额': 'amount', + '交易流水号': 'ref_no', + '对方名称': 'counterparty', + '摘要': 'memo' + } + df = df.rename(columns=column_mapping) + df['date'] = pd.to_datetime(df['date']) + df['source'] = 'bank' + + return df + +def load_system_records(file_path: str) -> pd.DataFrame: + """加载系统收付款记录""" + df = pd.read_excel(file_path) if file_path.endswith('.xlsx') else pd.read_csv(file_path) + + column_mapping = { + '单据日期': 'date', + '金额': 'amount', + '关联流水号': 'ref_no', + '客户/供应商': 'counterparty', + '单据编号': 'doc_no' + } + df = df.rename(columns=column_mapping) + df['date'] = pd.to_datetime(df['date']) + df['source'] = 'system' + + return df + +def reconcile(bank_df: pd.DataFrame, system_df: pd.DataFrame) -> dict: + """ + 执行对账 + + Returns: + { + 'matched': 已匹配记录, + 'bank_only': 银行有/系统无 (银行未达), + 'system_only': 系统有/银行无 (企业未达), + 'summary': 对账汇总 + } + """ + # 方法1: 按流水号精确匹配 + bank_with_ref = bank_df[bank_df['ref_no'].notna() & (bank_df['ref_no'] != '')] + system_with_ref = system_df[system_df['ref_no'].notna() & (system_df['ref_no'] != '')] + + matched_by_ref = pd.merge( + bank_with_ref, + system_with_ref, + on='ref_no', + suffixes=('_bank', '_system'), + how='inner' + ) + + matched_refs = set(matched_by_ref['ref_no']) + + # 方法2: 无流水号的按金额+日期模糊匹配 + bank_unmatched = bank_df[~bank_df['ref_no'].isin(matched_refs)] + system_unmatched = system_df[~system_df['ref_no'].isin(matched_refs)] + + # 按金额和日期(±3天)匹配 + fuzzy_matched = [] + bank_remaining = bank_unmatched.copy() + system_remaining = system_unmatched.copy() + + for idx, bank_row in bank_unmatched.iterrows(): + for sys_idx, sys_row in system_remaining.iterrows(): + if (abs(bank_row['amount'] - sys_row['amount']) < 0.01 and + abs((bank_row['date'] - sys_row['date']).days) <= 3): + fuzzy_matched.append({ + 'bank_idx': idx, + 'system_idx': sys_idx, + 'amount': bank_row['amount'], + 'bank_date': bank_row['date'], + 'system_date': sys_row['date'] + }) + system_remaining = system_remaining.drop(sys_idx) + bank_remaining = bank_remaining.drop(idx) + break + + # 未匹配项 + bank_only = bank_remaining # 银行有/系统无 + system_only = system_remaining # 系统有/银行无 + + # 汇总 + summary = { + 'bank_total': bank_df['amount'].sum(), + 'system_total': system_df['amount'].sum(), + 'matched_count': len(matched_by_ref) + len(fuzzy_matched), + 'bank_only_count': len(bank_only), + 'bank_only_amount': bank_only['amount'].sum() if len(bank_only) > 0 else 0, + 'system_only_count': len(system_only), + 'system_only_amount': system_only['amount'].sum() if len(system_only) > 0 else 0, + 'difference': bank_df['amount'].sum() - system_df['amount'].sum() + } + + return { + 'matched': pd.concat([matched_by_ref, pd.DataFrame(fuzzy_matched)]), + 'bank_only': bank_only, + 'system_only': system_only, + 'summary': summary + } +``` + +### 银行余额调节表 + +```python +def generate_bank_reconciliation_report( + bank_balance: float, + book_balance: float, + bank_only: pd.DataFrame, + system_only: pd.DataFrame, + as_of_date: str +) -> str: + """生成银行余额调节表""" + + # 分类未达账项 + bank_receipts_not_in_book = bank_only[bank_only['amount'] > 0]['amount'].sum() + bank_payments_not_in_book = bank_only[bank_only['amount'] < 0]['amount'].sum() + book_receipts_not_in_bank = system_only[system_only['amount'] > 0]['amount'].sum() + book_payments_not_in_bank = system_only[system_only['amount'] < 0]['amount'].sum() + + report = f""" +================================================================================ + 银行存款余额调节表 + 截止日期: {as_of_date} +================================================================================ + +一、银行对账单余额 {bank_balance:>15,.2f} + + 加: 企业已收/银行未收 {book_receipts_not_in_bank:>15,.2f} + 减: 企业已付/银行未付 {abs(book_payments_not_in_bank):>15,.2f} + ───────────────── + 调节后余额 {bank_balance + book_receipts_not_in_bank + book_payments_not_in_bank:>15,.2f} + +-------------------------------------------------------------------------------- + +二、企业账面余额 {book_balance:>15,.2f} + + 加: 银行已收/企业未收 {bank_receipts_not_in_book:>15,.2f} + 减: 银行已付/企业未付 {abs(bank_payments_not_in_book):>15,.2f} + ───────────────── + 调节后余额 {book_balance + bank_receipts_not_in_book + bank_payments_not_in_book:>15,.2f} + +================================================================================ + 未达账项明细 +================================================================================ + +【银行已收/企业未收】 +""" + + if len(bank_only[bank_only['amount'] > 0]) > 0: + for _, row in bank_only[bank_only['amount'] > 0].iterrows(): + report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n" + else: + report += " (无)\n" + + report += "\n【银行已付/企业未付】\n" + if len(bank_only[bank_only['amount'] < 0]) > 0: + for _, row in bank_only[bank_only['amount'] < 0].iterrows(): + report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n" + else: + report += " (无)\n" + + report += "\n【企业已收/银行未收】\n" + if len(system_only[system_only['amount'] > 0]) > 0: + for _, row in system_only[system_only['amount'] > 0].iterrows(): + report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n" + else: + report += " (无)\n" + + report += "\n【企业已付/银行未付】\n" + if len(system_only[system_only['amount'] < 0]) > 0: + for _, row in system_only[system_only['amount'] < 0].iterrows(): + report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n" + else: + report += " (无)\n" + + return report +``` + +### 应收对账 + +```python +def generate_ar_statement( + customer_name: str, + transactions: pd.DataFrame, + as_of_date: str +) -> str: + """ + 生成客户对账单 + + transactions 格式: + - date: 日期 + - doc_type: 单据类型 (发票/收款/退货) + - doc_no: 单据编号 + - debit: 借方(应收增加) + - credit: 贷方(应收减少) + - memo: 摘要 + """ + + df = transactions.sort_values('date') + + report = f""" +================================================================================ + 客户对账单 +================================================================================ +客户名称: {customer_name} +对账期间: {df['date'].min().strftime('%Y-%m-%d')} 至 {as_of_date} +================================================================================ + +日期 单据类型 单据编号 借方 贷方 余额 +-------------------------------------------------------------------------------- +""" + + balance = 0 + for _, row in df.iterrows(): + debit = row.get('debit', 0) or 0 + credit = row.get('credit', 0) or 0 + balance += debit - credit + + report += f"{row['date'].strftime('%Y-%m-%d')} {row['doc_type']:<8} {row['doc_no']:<16} {debit:>12,.2f} {credit:>12,.2f} {balance:>12,.2f}\n" + + report += f""" +-------------------------------------------------------------------------------- +合计 {df['debit'].sum():>12,.2f} {df['credit'].sum():>12,.2f} {balance:>12,.2f} +================================================================================ + +期末应收余额: {balance:,.2f} + +请贵司核对以上账目,如有异议请于收到对账单后 7 日内书面告知。 +如无异议,视为确认。 + +对账联系人: _______________ +联系电话: _______________ +""" + + return report +``` + +### 账龄分析 + +```python +from datetime import datetime, timedelta + +def aging_analysis( + receivables: pd.DataFrame, + as_of_date: str, + aging_buckets: list = [30, 60, 90, 180, 365] +) -> pd.DataFrame: + """ + 应收账龄分析 + + receivables 格式: + - customer: 客户 + - invoice_date: 发票日期 + - amount: 未收金额 + """ + + as_of = datetime.strptime(as_of_date, '%Y-%m-%d') + + def get_aging_bucket(invoice_date): + days = (as_of - invoice_date).days + for i, bucket in enumerate(aging_buckets): + if days <= bucket: + if i == 0: + return f'0-{bucket}天' + else: + return f'{aging_buckets[i-1]+1}-{bucket}天' + return f'{aging_buckets[-1]+1}天以上' + + df = receivables.copy() + df['invoice_date'] = pd.to_datetime(df['invoice_date']) + df['aging_days'] = (as_of - df['invoice_date']).dt.days + df['aging_bucket'] = df['invoice_date'].apply(get_aging_bucket) + + # 按客户和账龄分组汇总 + summary = df.pivot_table( + index='customer', + columns='aging_bucket', + values='amount', + aggfunc='sum', + fill_value=0 + ) + + # 添加合计列 + summary['合计'] = summary.sum(axis=1) + + # 添加合计行 + summary.loc['合计'] = summary.sum() + + return summary +``` + +--- + +## 对账报告模板 + +### Excel 对账报告 + +```python +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, Border, Side, PatternFill + +def export_reconciliation_to_excel( + result: dict, + output_path: str, + report_date: str +): + """导出对账结果到 Excel""" + + wb = Workbook() + + # Sheet1: 对账汇总 + ws_summary = wb.active + ws_summary.title = "对账汇总" + + summary = result['summary'] + ws_summary['A1'] = '对账汇总报告' + ws_summary['A1'].font = Font(bold=True, size=14) + ws_summary['A2'] = f'对账日期: {report_date}' + + headers = ['项目', '金额', '笔数'] + for col, header in enumerate(headers, 1): + ws_summary.cell(row=4, column=col, value=header) + + data = [ + ('银行流水合计', summary['bank_total'], '-'), + ('系统记录合计', summary['system_total'], '-'), + ('已匹配', '-', summary['matched_count']), + ('银行有/系统无', summary['bank_only_amount'], summary['bank_only_count']), + ('系统有/银行无', summary['system_only_amount'], summary['system_only_count']), + ('差异金额', summary['difference'], '-'), + ] + + for row, (item, amount, count) in enumerate(data, 5): + ws_summary.cell(row=row, column=1, value=item) + ws_summary.cell(row=row, column=2, value=amount) + ws_summary.cell(row=row, column=3, value=count) + + # Sheet2: 银行未达账项 + ws_bank = wb.create_sheet("银行未达账项") + if len(result['bank_only']) > 0: + for col, header in enumerate(result['bank_only'].columns, 1): + ws_bank.cell(row=1, column=col, value=header) + for row, data_row in enumerate(result['bank_only'].values, 2): + for col, value in enumerate(data_row, 1): + ws_bank.cell(row=row, column=col, value=value) + + # Sheet3: 企业未达账项 + ws_system = wb.create_sheet("企业未达账项") + if len(result['system_only']) > 0: + for col, header in enumerate(result['system_only'].columns, 1): + ws_system.cell(row=1, column=col, value=header) + for row, data_row in enumerate(result['system_only'].values, 2): + for col, value in enumerate(data_row, 1): + ws_system.cell(row=row, column=col, value=value) + + wb.save(output_path) + print(f"对账报告已保存: {output_path}") +``` + +--- + +## 常见对账场景 + +### 场景1: 月末银行对账 + +```python +# 1. 加载数据 +bank_df = load_bank_statement('银行流水_202601.xlsx') +system_df = load_system_records('收付款记录_202601.xlsx') + +# 2. 执行对账 +result = reconcile(bank_df, system_df) + +# 3. 查看汇总 +print(f"已匹配: {result['summary']['matched_count']} 笔") +print(f"银行未达: {result['summary']['bank_only_count']} 笔, 金额 {result['summary']['bank_only_amount']:,.2f}") +print(f"企业未达: {result['summary']['system_only_count']} 笔, 金额 {result['summary']['system_only_amount']:,.2f}") + +# 4. 生成调节表 +report = generate_bank_reconciliation_report( + bank_balance=150000.00, # 银行对账单余额 + book_balance=145000.00, # 企业账面余额 + bank_only=result['bank_only'], + system_only=result['system_only'], + as_of_date='2026-01-31' +) +print(report) + +# 5. 导出 Excel +export_reconciliation_to_excel(result, '银行对账_202601.xlsx', '2026-01-31') +``` + +### 场景2: 客户对账 + +```python +# 加载客户交易明细 +ar_transactions = pd.read_excel('应收明细.xlsx') + +# 筛选特定客户 +customer_data = ar_transactions[ar_transactions['customer'] == '深圳XX公司'] + +# 生成对账单 +statement = generate_ar_statement( + customer_name='深圳XX公司', + transactions=customer_data, + as_of_date='2026-01-31' +) +print(statement) +``` + +### 场景3: 账龄分析 + +```python +# 加载未收款数据 +receivables = pd.read_excel('应收账款.xlsx') + +# 账龄分析 +aging = aging_analysis( + receivables=receivables, + as_of_date='2026-01-31', + aging_buckets=[30, 60, 90, 180, 365] +) +print(aging) + +# 导出 +aging.to_excel('账龄分析_202601.xlsx') +``` + +### 场景4: 扫描单据OCR识别与对账匹配(诉讼证据核对) + +适用于:承运商/供应商提供扫描件作为诉讼证据,需要与我司对账单进行匹配核对。 + +```python +from pdf2image import convert_from_path +import pytesseract +import pandas as pd +import re + +def ocr_pdf_extract_orders(pdf_path: str, dpi: int = 150) -> pd.DataFrame: + """ + 从 PDF 扫描件中 OCR 提取订单信息 + + Args: + pdf_path: PDF 文件路径 + dpi: 图像分辨率,越高识别越准但速度越慢 + + Returns: + DataFrame 包含 order_no, page, tc_no 等字段 + """ + print(f"正在转换 PDF...") + images = convert_from_path(pdf_path, dpi=dpi) + print(f"共 {len(images)} 页,开始 OCR 识别...") + + all_records = [] + for i, img in enumerate(images, 1): + if i % 10 == 0: + print(f" 处理进度: {i}/{len(images)}") + + text = pytesseract.image_to_string(img, lang='chi_sim+eng') + + # 提取多种订单号格式(根据实际业务调整) + hm_orders = re.findall(r'(HM\d{12,15})(?:-\d+)?', text) # 华住订单 + hpo_orders = re.findall(r'(HPO-\d{8}-\d+)', text) # 华住采购订单 + fy_orders = re.findall(r'(FY\d{9,})', text) # 凤悦订单 + phgco_orders = re.findall(r'(PHGCO\d{9,})', text) # 锦江订单 + tc_orders = re.findall(r'(TC\d{8,})', text) # 运单号 + + # 提取日期 + dates = re.findall(r'(\d{4}-\d{2}-\d{2})', text) + + all_orders = hm_orders + hpo_orders + fy_orders + phgco_orders + for order in all_orders: + all_records.append({ + 'order_no': order, + 'page': i, + 'tc_no': tc_orders[0] if tc_orders else None, + 'date': dates[0] if dates else None, + 'raw_text': text[:500] # 保留部分原文用于核查 + }) + + df = pd.DataFrame(all_records) + df = df.drop_duplicates(subset=['order_no']) + print(f"提取完成,共 {len(df)} 条唯一订单") + return df + + +def extract_base_order_no(order_no: str) -> str: + """ + 提取基础订单号(移除后缀如 -1, -2) + + 例如: HM202505227320-1 -> HM202505227320 + """ + if pd.isna(order_no): + return None + order_str = str(order_no) + match = re.match(r'(HM\d{12,15}|HPO-\d{8}-\d+|FY\d{9,}|PHGCO\d{9,})', order_str) + return match.group(1) if match else order_str + + +def reconcile_scanned_with_excel( + scanned_df: pd.DataFrame, + excel_path: str, + main_sheet: str, + pending_sheet: str = None, + order_column: str = '华住/锦江订单号' +) -> dict: + """ + 扫描单据与 Excel 对账单匹配 + + Args: + scanned_df: OCR 提取的订单 DataFrame + excel_path: Excel 对账单路径 + main_sheet: 主数据 Sheet 名称 + pending_sheet: 待结算/次月结 Sheet 名称(可选) + order_column: 订单号列名 + + Returns: + { + 'matched_main': 在主表中匹配的订单, + 'matched_pending': 在待结算表中匹配的订单, + 'not_found': 未找到的订单, + 'summary': 汇总统计 + } + """ + # 加载主表 + df_main = pd.read_excel(excel_path, sheet_name=main_sheet) + # 处理表头(如果第一行是表头) + if df_main.iloc[0].astype(str).str.contains(order_column).any(): + df_main.columns = df_main.iloc[0] + df_main = df_main.iloc[1:].reset_index(drop=True) + + # 提取基础订单号 + main_orders = set(df_main[order_column].apply(extract_base_order_no).dropna()) + + # 加载待结算表(如有) + pending_orders = set() + df_pending = None + if pending_sheet: + try: + df_pending = pd.read_excel(excel_path, sheet_name=pending_sheet) + pending_orders = set(df_pending[order_column].apply(extract_base_order_no).dropna()) + except: + pass + + all_excel_orders = main_orders | pending_orders + + # 扫描件订单号 + scan_orders = set(scanned_df['order_no'].apply(extract_base_order_no).dropna()) + + # 匹配分析 + matched_main = scan_orders & main_orders + matched_pending = scan_orders & pending_orders + not_found = scan_orders - all_excel_orders + + # 汇总 + summary = { + 'scan_total': len(scan_orders), + 'matched_main_count': len(matched_main), + 'matched_pending_count': len(matched_pending), + 'not_found_count': len(not_found), + 'match_rate': (len(matched_main) + len(matched_pending)) / len(scan_orders) * 100 if scan_orders else 0 + } + + return { + 'matched_main': matched_main, + 'matched_pending': matched_pending, + 'not_found': not_found, + 'summary': summary, + 'df_main': df_main, + 'df_pending': df_pending + } + + +def generate_litigation_reconciliation_report( + result: dict, + scanned_df: pd.DataFrame, + output_path: str +): + """ + 生成诉讼对账分析报告 + """ + summary = result['summary'] + + with pd.ExcelWriter(output_path, engine='openpyxl') as writer: + # Sheet1: 汇总 + summary_df = pd.DataFrame({ + '项目': [ + '扫描件订单数', + '在主对账单中找到', + '在待结算表中找到', + '完全未找到', + '匹配率' + ], + '数值': [ + summary['scan_total'], + summary['matched_main_count'], + summary['matched_pending_count'], + summary['not_found_count'], + f"{summary['match_rate']:.1f}%" + ] + }) + summary_df.to_excel(writer, sheet_name='对账汇总', index=False) + + # Sheet2: 未找到的订单 + if result['not_found']: + not_found_df = scanned_df[ + scanned_df['order_no'].apply(extract_base_order_no).isin(result['not_found']) + ][['order_no', 'page', 'tc_no', 'date']] + not_found_df.columns = ['订单号', 'PDF页码', '运单号', '日期'] + not_found_df.to_excel(writer, sheet_name='未找到订单', index=False) + + # Sheet3: 待结算订单详情 + if result['matched_pending'] and result['df_pending'] is not None: + pending_detail = result['df_pending'][ + result['df_pending']['华住/锦江订单号'].apply(extract_base_order_no).isin(result['matched_pending']) + ] + pending_detail.to_excel(writer, sheet_name='待结算订单', index=False) + + print(f"报告已保存: {output_path}") +``` + +#### 使用示例 + +```python +# 1. 从 PDF 扫描件提取订单 +pdf_path = "承运商证据_6月业务.pdf" +scanned_df = ocr_pdf_extract_orders(pdf_path, dpi=150) + +# 2. 与 Excel 对账单匹配 +result = reconcile_scanned_with_excel( + scanned_df=scanned_df, + excel_path="对账单_2025年6月.xlsx", + main_sheet="2025.6", + pending_sheet="次月结", + order_column="华住/锦江订单号" +) + +# 3. 查看匹配结果 +print(f"匹配率: {result['summary']['match_rate']:.1f}%") +print(f"未找到: {result['summary']['not_found_count']} 条") + +if result['not_found']: + print("\n未找到的订单:") + for order in result['not_found']: + print(f" {order}") + +# 4. 生成报告 +generate_litigation_reconciliation_report( + result=result, + scanned_df=scanned_df, + output_path="诉讼对账分析报告.xlsx" +) +``` + +#### OCR 识别注意事项 + +1. **环境依赖**: + ```bash + # macOS + brew install tesseract tesseract-lang poppler + pip install pdf2image pytesseract pandas openpyxl + ``` + +2. **识别精度**: + - DPI 150 适合快速扫描,DPI 200-250 适合精确识别 + - 中文识别需要 `chi_sim` 语言包 + +3. **订单号格式差异**: + - PDF 中可能是 `HM202505227320` + - Excel 中可能是 `HM202505227320-1`、`HM202505227320-2`(带后缀) + - 匹配时应使用基础订单号 + +4. **常见问题**: + - 空白页(签收单背面)会被自动跳过 + - OCR 可能误识别字符(如 5→S, 0→O),建议人工抽查 + - 大 PDF 处理较慢,可先用低 DPI 预览 + +5. **"次月结"/"待结算" 订单**: + - 这类订单通常标记为"没有报价"、"未建单"等 + - 虽然在系统中有记录,但可能未计入正式结算金额 + - 诉讼中需特别关注这类订单的状态 + +6. **多承运商订单归属校验(重要风险点)**: + - 承运商可能将其他公司的订单混入证据,企图多收运费 + - 必须检查PDF订单是否出现在其他承运商的对账单中 + - 如果订单同时出现在多个承运商对账单中,则为异常订单 + +#### 多承运商订单归属校验 + +```python +def check_order_ownership( + scanned_df: pd.DataFrame, + my_company_excel: str, + other_carrier_excels: list[str], + order_column: str = '华住/锦江订单号' +) -> dict: + """ + 检查订单归属,识别是否有其他承运商的订单被混入 + + Args: + scanned_df: OCR 提取的订单 DataFrame + my_company_excel: 我司对账单路径 + other_carrier_excels: 其他承运商对账单路径列表 + order_column: 订单号列名 + + Returns: + { + 'my_orders': 属于我司的订单, + 'other_carrier_orders': 属于其他承运商的订单(异常), + 'disputed_orders': 同时出现在多处的争议订单, + 'summary': 汇总 + } + """ + # 提取PDF订单 + scan_orders = set(scanned_df['order_no'].apply(extract_base_order_no).dropna()) + + # 加载我司对账单订单 + my_orders = set() + try: + xl = pd.ExcelFile(my_company_excel) + for sheet in xl.sheet_names: + df = pd.read_excel(my_company_excel, sheet_name=sheet) + for col in df.columns: + if '订单' in str(col): + orders = df[col].apply(extract_base_order_no).dropna() + my_orders.update(orders) + except Exception as e: + print(f"加载我司对账单失败: {e}") + + # 加载其他承运商对账单订单 + other_orders = {} # {carrier_name: set of orders} + for excel_path in other_carrier_excels: + carrier_name = excel_path.split('/')[-1] + carrier_orders = set() + try: + xl = pd.ExcelFile(excel_path) + for sheet in xl.sheet_names: + df = pd.read_excel(excel_path, sheet_name=sheet) + for col in df.columns: + if '订单' in str(col): + orders = df[col].apply(extract_base_order_no).dropna() + carrier_orders.update(orders) + other_orders[carrier_name] = carrier_orders + except: + pass + + # 分析订单归属 + all_other_orders = set() + for orders in other_orders.values(): + all_other_orders.update(orders) + + # 分类 + only_mine = scan_orders & my_orders - all_other_orders # 仅在我司 + only_others = scan_orders & all_other_orders - my_orders # 仅在其他承运商(异常!) + in_both = scan_orders & my_orders & all_other_orders # 同时出现(争议) + nowhere = scan_orders - my_orders - all_other_orders # 哪里都没有 + + # 识别异常订单属于哪个承运商 + abnormal_details = [] + for order in only_others: + for carrier, orders in other_orders.items(): + if order in orders: + page = scanned_df[scanned_df['order_no'].apply(extract_base_order_no) == order]['page'].values + abnormal_details.append({ + 'order_no': order, + 'belongs_to': carrier, + 'pdf_page': page[0] if len(page) > 0 else None + }) + + summary = { + 'scan_total': len(scan_orders), + 'my_orders_count': len(only_mine), + 'other_carrier_count': len(only_others), # 异常订单数 + 'disputed_count': len(in_both), + 'not_found_count': len(nowhere) + } + + return { + 'my_orders': only_mine, + 'other_carrier_orders': only_others, + 'abnormal_details': abnormal_details, + 'disputed_orders': in_both, + 'not_found': nowhere, + 'summary': summary + } +``` + +#### 使用示例(多承运商校验) + +```python +# 检查订单归属 +result = check_order_ownership( + scanned_df=scanned_df, + my_company_excel="智慧云彩对账单_2025年6月.xlsx", + other_carrier_excels=[ + "友利速运对账单_2025年6月.xlsx", + "其他承运商对账单.xlsx" + ] +) + +# 检查是否有异常订单 +if result['summary']['other_carrier_count'] > 0: + print(f"⚠️ 发现 {result['summary']['other_carrier_count']} 个异常订单!") + print("这些订单属于其他承运商,不应由我司支付:") + for item in result['abnormal_details']: + print(f" {item['order_no']} -> 属于 {item['belongs_to']} (PDF第{item['pdf_page']}页)") +else: + print("✓ 未发现异常订单,所有订单归属正确") +``` + +--- + +## 注意事项 + +1. **数据格式统一**: 确保日期、金额格式一致后再对账 +2. **匹配规则灵活**: 先精确匹配流水号,再模糊匹配金额+日期 +3. **差异原因分析**: 对未达账项要逐笔核实原因 +4. **定期对账**: 建议至少每月进行一次银行对账 +5. **凭证留存**: 对账报告需打印签字存档 +6. **OCR 核对**: 扫描件 OCR 后应人工抽查,确保识别准确 +7. **多承运商校验**: 诉讼场景下必须检查订单是否属于其他承运商,防止对方混入他人订单骗取运费 + +--- + +## 与其他技能协作 + +- **data-excel**: 处理复杂的 Excel 数据转换 +- **feishu**: 将对账报告保存到飞书云文档 +- **siyuan**: 记录对账工作日志到思源笔记 diff --git a/plugins/finishing-a-development-branch-plugin/.claude-plugin/plugin.json b/plugins/finishing-a-development-branch-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..1713e0d --- /dev/null +++ b/plugins/finishing-a-development-branch-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "finishing-a-development-branch-plugin", + "description": "Plugin for finishing-a-development-branch", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/finishing-a-development-branch-plugin/skills/SKILL.md b/plugins/finishing-a-development-branch-plugin/skills/SKILL.md new file mode 100644 index 0000000..b07c4ee --- /dev/null +++ b/plugins/finishing-a-development-branch-plugin/skills/SKILL.md @@ -0,0 +1,104 @@ +--- +name: finishing-a-development-branch +description: Use when implementation is complete and all tests pass - verifies and creates PR +--- + +# Finishing a Development Branch + +## Overview + +Verify tests pass, then push and create PR. + +**Core principle:** Verify tests → Create PR → Done. + +**Announce at start:** "I'm using the finishing-a-development-branch skill to complete this work." + +## The Process + +### Step 1: Verify Tests + +**Before creating PR, verify tests pass:** + +```bash +# Run project's test suite +npm test / cargo test / pytest / go test ./... / mvn test +``` + +**If tests fail:** +``` +Tests failing ( failures). Must fix before completing: + +[Show failures] + +Cannot proceed with PR until tests pass. +``` + +Stop. Fix tests first. + +**If tests pass:** Continue to Step 2. + +### Step 2: Push and Create PR + +Use the `/pr create` command which will: +1. **Check for existing PR first** - avoids duplicates +2. If PR exists: Report existing PR URL and skip +3. If no PR: Analyze commits, generate title/description, push, create PR + +```bash +/pr create +``` + +**Duplicate prevention:** The `/pr create` command checks for existing open PRs on the current branch before creating a new one. + +Report the PR URL when complete (whether existing or newly created). + +### Step 3: Cleanup Worktree (if applicable) + +Check if working in a worktree: +```bash +git worktree list | grep $(git branch --show-current) +``` + +If yes, ask user: +``` +Worktree at . Remove it now? (y/n) +``` + +If confirmed: +```bash +git worktree remove +``` + +## Quick Reference + +``` +Tests Pass? + ↓ yes +/pr create + ↓ +PR URL returned + ↓ +Cleanup worktree (optional) + ↓ +Done +``` + +## Red Flags + +**Never:** +- Create PR with failing tests +- Skip test verification +- Force-push without explicit request + +**Always:** +- Verify tests before creating PR +- Use `/pr create` for consistent PR format +- Report the PR URL + +## Integration + +**Called by:** +- **executing-plans** (Step 6) - After all batches complete + +**Uses:** +- **/pr create** - For pushing and PR creation diff --git a/plugins/frontend-design-plugin/.claude-plugin/plugin.json b/plugins/frontend-design-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..808f1e5 --- /dev/null +++ b/plugins/frontend-design-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "frontend-design-plugin", + "description": "Plugin for frontend-design", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/frontend-design-plugin/skills/SKILL.md b/plugins/frontend-design-plugin/skills/SKILL.md new file mode 100644 index 0000000..f34ae42 --- /dev/null +++ b/plugins/frontend-design-plugin/skills/SKILL.md @@ -0,0 +1,695 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics. +arguments: [component|page|storybook] +--- + +# Frontend Design 前端设计技能 + +创建高质量、有设计感的前端界面和组件,支持 Storybook 组件开发。 + +--- + +## 命令格式 + +| 命令 | 功能 | 示例 | +|------|------|------| +| `/frontend-design component <描述>` | 创建 React/Vue 组件 | `/frontend-design component 产品卡片` | +| `/frontend-design page <描述>` | 创建完整页面 | `/frontend-design page 登录页` | +| `/frontend-design storybook <描述>` | 创建带 Storybook 的组件 | `/frontend-design storybook 按钮组件` | + +--- + +## 设计原则 + +### 1. 设计思维先行 + +在编码前,明确以下问题: + +- **目的**:这个界面解决什么问题?谁在使用? +- **调性**:选择一个明确的美学方向 +- **差异化**:什么让这个设计令人难忘? + +### 2. 美学方向选择 + +| 风格 | 特点 | 适用场景 | +|------|------|----------| +| 极简主义 | 大量留白、精炼元素 | 工具类、专业平台 | +| 现代商务 | 清晰层次、专业配色 | 企业官网、B2B | +| 活力年轻 | 鲜艳色彩、动感动画 | 消费品、社交 | +| 奢华精致 | 深色调、金属质感 | 高端品牌、金融 | +| 自然有机 | 柔和曲线、自然色系 | 健康、环保 | +| 复古怀旧 | 经典字体、做旧质感 | 文化、艺术 | +| 未来科技 | 渐变、玻璃拟态 | 科技、创新 | + +### 3. 避免的设计陷阱 + +**禁止使用**: +- 过度使用的字体:Inter、Roboto、Arial +- 陈词滥调的配色:紫色渐变白底 +- 千篇一律的布局 +- 缺乏个性的通用组件 + +**应该追求**: +- 独特的字体组合 +- 有意图的配色方案 +- 打破常规的布局 +- 有记忆点的细节 + +--- + +## Storybook 组件开发 + +### 项目结构 + +``` +src/ +├── components/ +│ ├── Button/ +│ │ ├── Button.tsx +│ │ ├── Button.stories.tsx +│ │ ├── Button.module.css +│ │ └── index.ts +│ ├── Card/ +│ │ ├── Card.tsx +│ │ ├── Card.stories.tsx +│ │ ├── Card.module.css +│ │ └── index.ts +│ └── index.ts +├── styles/ +│ ├── variables.css +│ ├── typography.css +│ └── animations.css +└── .storybook/ + ├── main.ts + └── preview.ts +``` + +### 组件模板 + +#### 1. 组件文件 (Component.tsx) + +```tsx +import React from 'react'; +import styles from './Component.module.css'; + +export interface ComponentProps { + /** 组件变体 */ + variant?: 'primary' | 'secondary' | 'outline'; + /** 尺寸 */ + size?: 'sm' | 'md' | 'lg'; + /** 是否禁用 */ + disabled?: boolean; + /** 子元素 */ + children: React.ReactNode; + /** 点击事件 */ + onClick?: () => void; +} + +export const Component: React.FC = ({ + variant = 'primary', + size = 'md', + disabled = false, + children, + onClick, +}) => { + return ( +
+ {children} +
+ ); +}; +``` + +#### 2. Storybook Stories (Component.stories.tsx) + +```tsx +import type { Meta, StoryObj } from '@storybook/react'; +import { Component } from './Component'; + +const meta: Meta = { + title: 'Components/Component', + component: Component, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: '组件描述文档', + }, + }, + }, + argTypes: { + variant: { + control: 'select', + options: ['primary', 'secondary', 'outline'], + description: '组件变体样式', + }, + size: { + control: 'radio', + options: ['sm', 'md', 'lg'], + description: '组件尺寸', + }, + disabled: { + control: 'boolean', + description: '是否禁用', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** 默认状态 */ +export const Default: Story = { + args: { + children: '默认组件', + }, +}; + +/** 主要变体 */ +export const Primary: Story = { + args: { + variant: 'primary', + children: '主要按钮', + }, +}; + +/** 次要变体 */ +export const Secondary: Story = { + args: { + variant: 'secondary', + children: '次要按钮', + }, +}; + +/** 不同尺寸 */ +export const Sizes: Story = { + render: () => ( +
+ 小号 + 中号 + 大号 +
+ ), +}; + +/** 禁用状态 */ +export const Disabled: Story = { + args: { + disabled: true, + children: '禁用状态', + }, +}; +``` + +#### 3. 样式文件 (Component.module.css) + +```css +.component { + /* 基础样式 */ + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +/* 变体 */ +.primary { + background: var(--color-primary); + color: white; +} + +.primary:hover { + background: var(--color-primary-dark); + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--color-primary-shadow); +} + +.secondary { + background: var(--color-secondary); + color: var(--color-text); +} + +.outline { + background: transparent; + border: 2px solid var(--color-border); + color: var(--color-text); +} + +/* 尺寸 */ +.sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.md { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +.lg { + padding: 1rem 2rem; + font-size: 1.125rem; +} + +/* 状态 */ +[data-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} +``` + +--- + +## 设计系统变量 + +### CSS 变量模板 + +```css +:root { + /* 颜色 */ + --color-primary: #0066ff; + --color-primary-dark: #0052cc; + --color-primary-light: #4d94ff; + --color-primary-shadow: rgba(0, 102, 255, 0.25); + + --color-secondary: #f0f4f8; + --color-accent: #ff6b35; + + --color-text: #1a1a2e; + --color-text-muted: #64748b; + --color-text-inverse: #ffffff; + + --color-background: #ffffff; + --color-surface: #f8fafc; + --color-border: #e2e8f0; + + --color-success: #10b981; + --color-warning: #f59e0b; + --color-error: #ef4444; + + /* 字体 */ + --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif; + --font-display: 'Clash Display', var(--font-sans); + --font-mono: 'JetBrains Mono', monospace; + + /* 字号 */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 2rem; + --text-4xl: 2.5rem; + + /* 间距 */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-12: 3rem; + --space-16: 4rem; + + /* 圆角 */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 1rem; + --radius-xl: 1.5rem; + --radius-full: 9999px; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + + /* 动画 */ + --duration-fast: 150ms; + --duration-normal: 300ms; + --duration-slow: 500ms; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* 暗色主题 */ +[data-theme="dark"] { + --color-text: #f1f5f9; + --color-text-muted: #94a3b8; + --color-background: #0f172a; + --color-surface: #1e293b; + --color-border: #334155; +} +``` + +--- + +## 常用组件示例 + +### 1. 产品卡片 (ProductCard) + +```tsx +// ProductCard.tsx +import React from 'react'; +import styles from './ProductCard.module.css'; + +export interface ProductCardProps { + image: string; + title: string; + location: string; + rating: number; + reviewCount: number; + price: number; + originalPrice?: number; + tags?: string[]; + onAddToCart?: () => void; +} + +export const ProductCard: React.FC = ({ + image, + title, + location, + rating, + reviewCount, + price, + originalPrice, + tags = [], + onAddToCart, +}) => { + return ( +
+
+ {title} + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ +
+

{title}

+

📍 {location}

+ +
+ ⭐ {rating.toFixed(1)} + ({reviewCount}条评价) +
+ +
+
+ ¥ + {price} + +
+ {originalPrice && ( + ¥{originalPrice} + )} +
+ + +
+
+ ); +}; +``` + +```tsx +// ProductCard.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; +import { ProductCard } from './ProductCard'; + +const meta: Meta = { + title: 'Components/ProductCard', + component: ProductCard, + tags: ['autodocs'], + parameters: { + layout: 'centered', + backgrounds: { + default: 'light', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + image: 'https://images.unsplash.com/photo-1494947665470-20322015e3a8', + title: '袋鼠岛一日游', + location: '阿德莱德出发', + rating: 4.8, + reviewCount: 126, + price: 389, + tags: ['热卖', '含午餐'], + }, +}; + +export const WithDiscount: Story = { + args: { + ...Default.args, + originalPrice: 499, + tags: ['特惠', '限时'], + }, +}; + +export const Grid: Story = { + render: () => ( +
+ + + +
+ ), +}; +``` + +### 2. 按钮组件 (Button) + +```tsx +// Button.tsx +import React from 'react'; +import styles from './Button.module.css'; + +export interface ButtonProps { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + fullWidth?: boolean; + loading?: boolean; + disabled?: boolean; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + children: React.ReactNode; + onClick?: () => void; +} + +export const Button: React.FC = ({ + variant = 'primary', + size = 'md', + fullWidth = false, + loading = false, + disabled = false, + leftIcon, + rightIcon, + children, + onClick, +}) => { + return ( + + ); +}; +``` + +--- + +## Storybook 配置 + +### .storybook/main.ts + +```ts +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-a11y', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + docs: { + autodocs: 'tag', + }, +}; + +export default config; +``` + +### .storybook/preview.ts + +```ts +import type { Preview } from '@storybook/react'; +import '../src/styles/variables.css'; +import '../src/styles/typography.css'; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#ffffff' }, + { name: 'gray', value: '#f8fafc' }, + { name: 'dark', value: '#0f172a' }, + ], + }, + }, + globalTypes: { + theme: { + description: 'Global theme for components', + defaultValue: 'light', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: ['light', 'dark'], + dynamicTitle: true, + }, + }, + }, +}; + +export default preview; +``` + +--- + +## 快速启动命令 + +### 创建新组件 + +```bash +# 创建组件目录 +mkdir -p src/components/ComponentName + +# 创建文件 +touch src/components/ComponentName/{ComponentName.tsx,ComponentName.stories.tsx,ComponentName.module.css,index.ts} +``` + +### 安装 Storybook + +```bash +# 初始化 Storybook +npx storybook@latest init + +# 安装额外插件 +npm install -D @storybook/addon-a11y @storybook/addon-interactions + +# 启动 Storybook +npm run storybook +``` + +--- + +## 设计检查清单 + +### 组件质量检查 + +- [ ] Props 接口定义完整,带 JSDoc 注释 +- [ ] 支持必要的变体(variant)和尺寸(size) +- [ ] 处理禁用和加载状态 +- [ ] 支持自定义 className +- [ ] 键盘可访问性 +- [ ] 屏幕阅读器友好 + +### Storybook 质量检查 + +- [ ] 所有变体都有对应 Story +- [ ] argTypes 配置完整 +- [ ] 包含组件文档描述 +- [ ] 交互状态可测试 +- [ ] 响应式展示 + +### 视觉质量检查 + +- [ ] 字体选择有特色 +- [ ] 配色方案协调 +- [ ] 动画流畅自然 +- [ ] 间距一致 +- [ ] 暗色主题支持 diff --git a/plugins/gitea-plugin/.claude-plugin/plugin.json b/plugins/gitea-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..1a2bb7c --- /dev/null +++ b/plugins/gitea-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "gitea-plugin", + "description": "Gitea 代码托管与 CI/CD 管理。用于 Gitea Actions workflow 管理、Runner 管理、PR 操作、仓库配置。", + "version": "1.0.0", + "author": { + "name": "qiudl" + } +} diff --git a/plugins/gitea-plugin/scripts/gitea-runs b/plugins/gitea-plugin/scripts/gitea-runs new file mode 100755 index 0000000..a16da35 --- /dev/null +++ b/plugins/gitea-plugin/scripts/gitea-runs @@ -0,0 +1,211 @@ +#!/bin/bash +# gitea-runs — Gitea Actions CLI helper +# Usage: +# gitea-runs List recent runs +# gitea-runs list [limit] List recent runs (default 10) +# gitea-runs view View run details & jobs +# gitea-runs open [run_number] Open run in browser +# gitea-runs workflows List workflows +# gitea-runs dispatch [ref] Trigger a workflow dispatch +# gitea-runs help Show this help + +set -e + +# Config from tea CLI +TEA_CONFIG="${XDG_CONFIG_HOME:-$HOME/Library/Application Support}/tea/config.yml" +if [ ! -f "$TEA_CONFIG" ]; then + TEA_CONFIG="$HOME/.config/tea/config.yml" +fi + +# Parse tea config (nested under logins) +GITEA_URL=$(grep 'url:' "$TEA_CONFIG" | head -1 | awk '{print $NF}') +GITEA_TOKEN=$(grep 'token:' "$TEA_CONFIG" | head -1 | awk '{print $NF}') + +# Detect repo from git remote +REPO=$(git remote get-url origin 2>/dev/null | sed 's|.*gitea.pipexerp.com[:/]*||;s|\.git$||;s|^10022/||') +if [ -z "$REPO" ]; then + echo "Error: not in a git repo or remote not configured" + exit 1 +fi + +API="$GITEA_URL/api/v1" +AUTH="Authorization: token $GITEA_TOKEN" + +GREEN='\033[0;32m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +cmd_dispatch() { + local workflow="${1:-}" + local ref="${2:-main}" + if [ -z "$workflow" ]; then + echo "Usage: gitea-runs dispatch [ref]" + echo "" + echo "Available workflows:" + curl -s -H "$AUTH" "$API/repos/$REPO/actions/workflows" 2>/dev/null \ + | python3 -c " +import json, sys +data = json.load(sys.stdin) +for w in data.get('workflows', []): + print(f\" {w['id']:30s} {w['name']}\") +" 2>/dev/null + return + fi + + local http_code + http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -X POST "$API/repos/$REPO/actions/workflows/$workflow/dispatches" \ + -d "{\"ref\":\"$ref\"}" 2>/dev/null) + + if [ "$http_code" = "204" ]; then + echo -e "${GREEN}✓${NC} Dispatched workflow: $workflow (ref: $ref)" + echo " View: $GITEA_URL/$REPO/actions" + else + echo -e "${RED}✗${NC} Failed to dispatch (HTTP $http_code)" + fi +} + +cmd_workflows() { + echo -e "${CYAN}Workflows for $REPO${NC}" + echo "" + curl -s -H "$AUTH" "$API/repos/$REPO/actions/workflows" 2>/dev/null \ + | python3 -c " +import json, sys +data = json.load(sys.stdin) +for w in data.get('workflows', []): + state = '✓' if w['state'] == 'active' else '✗' + print(f\" {state} {w['id']:30s} {w['name']}\") +" 2>/dev/null +} + +cmd_list() { + local limit="${1:-10}" + echo -e "${CYAN}Recent runs for $REPO${NC}" + echo "" + curl -s -H "$AUTH" "$API/repos/$REPO/actions/runs?limit=$limit" 2>/dev/null \ + | python3 -c " +import json, sys +limit = $limit +data = json.load(sys.stdin) +for r in data.get('workflow_runs', [])[:limit]: + status = r.get('status', '?') + num = r.get('run_number', 0) + title = r.get('display_title', '')[:60] + wf = r.get('path', '') + wf = wf.split('@')[0] if '@' in wf else wf + icon = {'success':'\u2713','completed':'\u2713','failure':'\u2717','cancelled':'\u2717','in_progress':'\u27f3','running':'\u27f3','queued':'\u25cc','waiting':'\u25cc'}.get(status, '?') + color = {'success':'\033[0;32m','completed':'\033[0;32m','failure':'\033[0;31m','cancelled':'\033[0;31m','in_progress':'\033[0;33m','running':'\033[0;33m'}.get(status, '\033[0;37m') + print(f\"{color}{icon}\033[0m #{num:<4} {status:<12} {wf:<20} {title}\") +" 2>/dev/null +} + +cmd_view() { + local run_number="${1:-}" + if [ -z "$run_number" ]; then + echo "Usage: gitea-runs view " + return 1 + fi + + # Find run by run_number (API uses internal id, html uses run_number) + local run_data + run_data=$(curl -s -H "$AUTH" "$API/repos/$REPO/actions/runs?limit=50" 2>/dev/null \ + | python3 -c " +import json, sys +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r['run_number'] == $run_number: + print(json.dumps(r)) + break +" 2>/dev/null) + + if [ -z "$run_data" ]; then + echo -e "${RED}✗${NC} Run #$run_number not found" + return 1 + fi + + local run_id + run_id=$(echo "$run_data" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + # Print run info + echo "$run_data" | python3 -c " +import json, sys +r = json.load(sys.stdin) +status = r.get('status', '?') +icon = {'success':'\u2713','failure':'\u2717','in_progress':'\u27f3','queued':'\u25cc'}.get(status, '?') +color = {'success':'\033[0;32m','failure':'\033[0;31m','in_progress':'\033[0;33m'}.get(status, '\033[0;37m') +print(f\"{color}{icon} Run #{r.get('run_number',0)} \u2014 {status}\033[0m\") +print(f\" Title: {r.get('display_title','')}\") +print(f\" Event: {r.get('event','')}\") +print(f\" Branch: {r.get('head_branch','')}\") +print(f\" Commit: {r.get('head_sha','')[:8]}\") +print(f\" Actor: {r.get('actor',{}).get('login','')}\") +wf = r.get('path', '') +wf = wf.split('@')[0] if '@' in wf else wf +print(f\" Workflow: {wf}\") +" 2>/dev/null + + # Print jobs + echo "" + echo -e "${CYAN}Jobs:${NC}" + curl -s -H "$AUTH" "$API/repos/$REPO/actions/runs/$run_id/jobs" 2>/dev/null \ + | python3 -c " +import json, sys +from datetime import datetime +data = json.load(sys.stdin) +for j in data.get('jobs', []): + status = j.get('status', '?') + icon = {'success':'\u2713','failure':'\u2717','in_progress':'\u27f3','queued':'\u25cc','waiting':'\u25cc'}.get(status, '?') + color = {'success':'\033[0;32m','failure':'\033[0;31m','in_progress':'\033[0;33m'}.get(status, '\033[0;37m') + runner = j.get('runner_name', '-') + started = j.get('started_at', '')[:19].replace('T', ' ') + completed = j.get('completed_at', '')[:19].replace('T', ' ') + duration = '' + if completed and not completed.startswith('1970'): + try: + d = datetime.fromisoformat(completed) - datetime.fromisoformat(started) + duration = f' ({int(d.total_seconds())}s)' + except: pass + print(f\" {color}{icon}\033[0m {j.get('name',''):<30} {status:<12} runner: {runner}{duration}\") +" 2>/dev/null +} + +cmd_open() { + local run_id="${1:-}" + local url="$GITEA_URL/$REPO/actions" + if [ -n "$run_id" ]; then + url="$url/runs/$run_id" + fi + echo "Opening: $url" + open "$url" 2>/dev/null || xdg-open "$url" 2>/dev/null || echo "$url" +} + +cmd_help() { + echo "gitea-runs — Gitea Actions CLI helper" + echo "" + echo "Usage:" + echo " gitea-runs List recent runs" + echo " gitea-runs list [limit] List recent runs (default 10)" + echo " gitea-runs view View run details & jobs" + echo " gitea-runs open [run_number] Open run in browser" + echo " gitea-runs workflows List workflows" + echo " gitea-runs dispatch [ref] Trigger a workflow dispatch" + echo " gitea-runs help Show this help" + echo "" + echo "Repo: $REPO" + echo "Gitea: $GITEA_URL" +} + +# Main +case "${1:-}" in + list|ls) shift; cmd_list "$@" ;; + view|v) shift; cmd_view "$@" ;; + dispatch) shift; cmd_dispatch "$@" ;; + workflows|wf) cmd_workflows ;; + open|o) shift; cmd_open "$@" ;; + help|--help|-h) cmd_help ;; + "") cmd_list ;; + *) cmd_view "$1" ;; +esac diff --git a/plugins/gitea-plugin/skills/SKILL.md b/plugins/gitea-plugin/skills/SKILL.md new file mode 100644 index 0000000..a829627 --- /dev/null +++ b/plugins/gitea-plugin/skills/SKILL.md @@ -0,0 +1,281 @@ +--- +name: gitea +description: Gitea 代码托管与 CI/CD 管理。用于 Gitea Actions workflow 管理、Runner 管理、PR 操作、仓库配置。当用户提到 Gitea、Actions、Runner、CI/CD workflow、PR 检查相关任务时自动激活。 +--- + +# Gitea Skill + +Gitea 代码托管平台管理,覆盖 Actions CI/CD、Runner、PR、仓库配置。 + +## 服务器信息 + +| 服务 | 地址 | SSH | +|------|------|-----| +| Gitea Web | https://gitea.pipexerp.com | — | +| Gitea SSH | gitea.pipexerp.com:10022 | `ssh -i ~/.ssh/id_ed25519 git@gitea.pipexerp.com -p 10022` | +| Gitea 服务器 | 123.56.89.187 | `ssh -i ~/.ssh/tools.pem root@123.56.89.187` | +| Runner 服务器 | 101.200.136.200 (Jenkins 服务器) | `ssh -i ~/.ssh/tools.pem root@101.200.136.200` | + +## API 访问 + +```bash +# Gitea API Token (仓库级) +GITEA_TOKEN="483a2b65219625ee382eb6d023cda39238c32e24" + +# 通用请求格式 +curl -s "https://gitea.pipexerp.com/api/v1/repos/pipexerp//..." \ + -H "Authorization: token $GITEA_TOKEN" +``` + +### 常用 API + +| 操作 | 方法 | 端点 | +|------|------|------| +| 创建 PR | POST | `/repos/{owner}/{repo}/pulls` | +| 更新 PR | PATCH | `/repos/{owner}/{repo}/pulls/{id}` | +| 列出 Runs | GET | `/repos/{owner}/{repo}/actions/runs` | +| Run 详情 | GET | `/repos/{owner}/{repo}/actions/runs/{id}` | +| Job 详情 | GET | `/repos/{owner}/{repo}/actions/runs/{id}/jobs` | +| 手动触发 Workflow | POST | `/repos/{owner}/{repo}/actions/workflows/{file}/dispatches` body: `{"ref":"main"}` | +| 获取 Runner Token | POST | `/repos/{owner}/{repo}/actions/runners/registration-token` | +| 添加 Secret | PUT | `/repos/{owner}/{repo}/actions/secrets/{name}` body: `{"data":"value"}` | +| 删除 Run(仅已完成)| DELETE | `/repos/{owner}/{repo}/actions/runs/{id}` | + +**注意**: Gitea 1.25 **不支持**通过 API cancel 正在排队/运行的 run。 + +## 仓库 + +| 仓库 | 地址 | 主分支 | +|------|------|--------| +| coolbuy-paas | pipexerp/coolbuy-paas | main | +| dotfiles | huangjun/dotfiles | main | +| claude-marketplace | huangjun/claude-marketplace | main | + +## Actions Runners + +### 主 Runner (lint/test/e2e) +| 项目 | 值 | +|------|-----| +| 名称 | jenkins-runner | +| 配置 | `/opt/act_runner/config.yaml` | +| Capacity | 3 | +| Labels | `ubuntu-latest`, `ubuntu-22.04`, `ubuntu-20.04` | +| 进程 | `/usr/local/bin/act_runner daemon --config /opt/act_runner/config.yaml` | + +### Deploy Runner (staging 部署专用) +| 项目 | 值 | +|------|-----| +| 名称 | deploy-runner | +| 配置 | `/opt/act_runner_deploy/config.yaml` | +| Capacity | 1 | +| Labels | `deploy:host` | +| PID | `/opt/act_runner_deploy/runner.pid` | +| 日志 | `/opt/act_runner_deploy/runner.log` | +| 启动 | `cd /opt/act_runner_deploy && nohup act_runner daemon --config config.yaml > runner.log 2>&1 &` | + +### 注册新 Runner + +```bash +# 1. 获取 registration token +curl -s -X POST "https://gitea.pipexerp.com/api/v1/repos/pipexerp/coolbuy-paas/actions/runners/registration-token" \ + -H "Authorization: token $GITEA_TOKEN" + +# 2. SSH 到 runner 服务器 +ssh -i ~/.ssh/tools.pem root@101.200.136.200 + +# 3. 创建目录和配置 +mkdir -p /opt/act_runner_ +cat > /opt/act_runner_/config.yaml << 'EOF' +log: + level: info +runner: + file: .runner + capacity: 1 + timeout: 30m + labels: + - "