commit 6aecef16f6636d50c3e2479d129d04202ba672ca Author: jc Date: Sun Apr 12 10:12:18 2026 +0800 初始提交:极码 GeekCode 全栈项目(FastAPI + Vue3) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3049f2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +venv/ +.venv/ +*.egg-info/ + +# 环境变量 +.env +.env.* + +# 数据库 +*.db +*.sqlite3 + +# 上传文件 +backend/uploads/* +!backend/uploads/.gitkeep + +# Node +node_modules/ +dist/ +dist-ssr/ +*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# macOS +.DS_Store +__MACOSX/ + +# 日志 +*.log +npm-debug.log* + +# 压缩包 +*.zip diff --git a/AI产品经理编程辅助工具-功能清单.md b/AI产品经理编程辅助工具-功能清单.md new file mode 100644 index 0000000..3b0af6e --- /dev/null +++ b/AI产品经理编程辅助工具-功能清单.md @@ -0,0 +1,249 @@ +# AI产品经理编程辅助工具 — 功能清单 + +> 产品定位:面向产品经理/开发者的轻量级AI编程辅助工具,包含需求理解、架构选型、经验沉淀三大核心能力。 +> +> 创建时间:2026年3月31日 + +--- + +## 产品概览 + +``` +┌──────────────────────────────────────────────┐ +│ AI产品经理编程辅助工具 │ +├──────────┬──────────┬────────────────────────┤ +│ 需求理解 │ 架构选型 │ 经验知识库 │ +│ 助手 │ 助手 │ (类论坛社区) │ +└──────────┴──────────┴────────────────────────┘ +``` + +--- + +## 模块一:需求理解助手 + +> 核心目标:把甲方发来的各种内容(口语化文字、截图、图片、文件等)翻译成清晰的功能列表。 + +### 1.1 多模态输入 +- 支持粘贴/输入甲方发来的口语化文字(如微信聊天记录、邮件内容) +- 支持上传图片(产品截图、手绘草图、竞品页面截图等) +- 支持上传文件(Word、PDF等需求文档) +- AI自动识别和理解所有输入内容 +- **推荐模型**: + - 图片理解:`Gemini 2.5 Pro`(多模态能力最强,图片/草图识别精准)或 `GPT-4o` + - 文件解析:`Claude 4 Sonnet`(超长上下文,适合处理长文档) + - 国产备选:`通义千问VL`(多模态)、`DeepSeek-V3`(文本理解) + +### 1.2 AI需求解读 +- 从杂乱的输入中提取核心意图:甲方到底想做什么 +- 输出结构化的功能清单:功能名称 + 简要说明 +- 自动过滤无关信息,聚焦产品功能本身 +- **推荐模型**: + - 首选:`Claude 4 Sonnet`(结构化输出稳定,逻辑推理强) + - 备选:`GPT-4o`、`DeepSeek-V3` + - 适合用系统提示词约束输出格式,确保每次输出一致 + +### 1.3 追问补全 +- AI识别需求中模糊或缺失的部分,主动追问 +- 如:"甲方说要做用户管理,但没提到权限,要不要考虑角色权限?" +- 逐步完善功能清单 +- **推荐模型**: + - 首选:`Claude 4 Sonnet`(多轮对话能力强,擅长发现遗漏) + - 备选:`GPT-4o` + - 需要较强的上下文记忆和逻辑推理能力 + +### 1.4 功能清单导出 +- 整理后的功能清单支持一键导出 +- 支持Markdown / PDF格式 +- 可直接作为开发任务的输入 +- **说明**:此功能为前端导出,不依赖大模型 + +--- + +## 模块二:开发架构AI助手 + +> 核心目标:根据需求特点,推荐合适的技术栈和架构方案,降低技术选型决策成本。 + +### 2.1 架构推荐对话 +- 输入项目需求和约束条件(团队规模、预算、时间、性能要求) +- AI推荐前端框架、后端框架、数据库、部署方案等 +- 给出推荐理由和各方案的优劣对比 +- **推荐模型**: + - 首选:`Claude 4 Sonnet`(代码和架构理解能力顶级) + - 备选:`GPT-4o`、`Gemini 2.5 Pro` + - 国产备选:`DeepSeek-V3`(编程能力强,性价比高) + +### 2.2 技术栈对比 +- 针对具体场景对比不同技术方案 +- 如:React vs Vue、MySQL vs PostgreSQL、单体 vs 微服务 +- 从性能、学习成本、生态、维护性等维度分析 +- **推荐模型**: + - 首选:`Claude 4 Sonnet` 或 `GPT-4o`(知识面广,技术细节准确) + - 可联网搜索增强,获取最新框架版本和社区动态 + +### 2.3 架构图生成 +- 根据选定方案自动生成系统架构图 +- 包含:前后端分层、数据流向、第三方服务集成 +- 支持编辑和导出 +- **推荐模型**: + - 首选:`Claude 4 Sonnet`(生成Mermaid/PlantUML代码最稳定) + - 备选:`GPT-4o` + - 前端用Mermaid.js渲染,模型只负责生成描述代码 + +### 2.4 项目结构建议 +- 根据选定技术栈推荐目录结构 +- 包含关键配置文件说明 +- 提供脚手架命令或初始化指引 +- **推荐模型**: + - 首选:`Claude 4 Sonnet`(对项目工程化理解最好) + - 备选:`DeepSeek-V3`(编程实践经验丰富) + +### 2.5 技术风险评估 +- 分析所选架构的潜在风险 +- 如:性能瓶颈、扩展性限制、技术债务 +- 给出规避建议和替代方案 +- **推荐模型**: + - 首选:`Claude 4 Sonnet`(推理分析能力强) + - 备选:`GPT-4o` + - 建议结合联网搜索,获取真实案例和社区反馈 + +--- + +## 模块三:经验知识库(类论坛社区) + +> 核心目标:像论坛一样记录和分享开发经验、踩坑记录、问题解决方案,形成可检索的知识沉淀。 + +### 3.1 经验记录(发帖) + +#### 发布功能 +- 支持发布经验帖,记录开发过程中的问题和解决方案 +- 富文本编辑器,支持Markdown、代码高亮、图片上传 +- 支持设置标签/分类(如:前端、后端、部署、踩坑、最佳实践) + +#### 模板引导 +- 提供常用记录模板: + - **踩坑记录**:问题描述 → 错误信息 → 排查过程 → 解决方案 + - **技术方案**:背景 → 方案对比 → 最终选择 → 实施效果 + - **工具使用**:工具介绍 → 安装配置 → 使用技巧 → 注意事项 +- 也可自由格式记录 + +### 3.2 智能分类与标签 +- AI自动识别内容主题,建议分类和标签 +- 支持自定义标签体系 +- 按技术栈、问题类型、项目等多维度归类 +- **推荐模型**: + - 首选:`GPT-4o-mini` 或 `DeepSeek-V3`(轻量任务,性价比优先) + - 分类打标签是简单任务,不需要最强模型,控制成本 + +### 3.3 搜索与检索 +- 全文搜索,支持关键词和语义搜索 +- 按标签、分类、时间等条件筛选 +- AI智能推荐相关经验帖("你遇到的问题,之前有类似记录") +- **推荐方案**: + - 向量化嵌入:`text-embedding-3-large`(OpenAI)或 `bge-large-zh`(国产,中文优化) + - 向量数据库:Milvus / Qdrant / pgvector + - 关键词搜索:Elasticsearch / MeiliSearch + +### 3.4 AI辅助回忆 +- 遇到问题时,描述问题让AI从知识库中检索相关经验 +- AI总结历史经验,给出参考建议 +- 关联外部资源(Stack Overflow、官方文档等) +- **推荐模型**: + - RAG检索 + `Claude 4 Sonnet` 或 `GPT-4o` 做总结回答 + - 检索用向量相似度匹配,回答用大模型生成 + - 国产备选:`DeepSeek-V3` + `bge-large-zh` + +### 3.5 互动功能 +- 点赞/收藏,标记有价值的经验 +- 评论区,补充更多解决方案或讨论 +- 支持置顶高频问题 + +### 3.6 个人中心 +- 我的发布记录 +- 我的收藏夹 +- 经验统计(发了多少帖、覆盖哪些技术领域) + +--- + +## 通用功能 + +### 用户系统 +- 注册/登录(邮箱、手机号、第三方登录) +- 个人资料管理 +- 权限管理(普通用户、管理员) + +### 界面设计 +- 简洁清爽的界面风格 +- 响应式设计,支持PC和移动端 +- 深色/浅色主题切换 + +### 数据与隐私 +- 用户数据加密存储 +- 支持私密记录(仅自己可见)和公开分享 +- 数据导出功能 + +--- + +## 实现优先级建议 + +### 第一期(MVP) +- [ ] 多模态输入(文字+图片)+ AI需求解读 +- [ ] 架构推荐对话 + 技术栈对比 +- [ ] 经验记录发帖 + 搜索检索 +- [ ] 基础用户系统 + +### 第二期(体验完善) +- [ ] 追问补全 + 功能清单导出 +- [ ] 架构图生成 + 项目结构建议 +- [ ] 智能分类标签 + AI辅助回忆 +- [ ] 互动功能(点赞/评论/收藏) + +### 第三期(能力增强) +- [ ] 文件上传解析(Word/PDF) +- [ ] 技术风险评估 +- [ ] 模板引导 + 个人中心 +- [ ] 语义搜索 + 智能推荐 + +--- + +## 技术架构建议 + +``` +┌─────────────────────────────────┐ +│ 前端(Web应用) │ +│ React/Vue + 响应式布局 │ +├─────────────────────────────────┤ +│ 后端API服务 │ +│ Python(FastAPI) / Node.js │ +├────────┬────────┬───────────────┤ +│ AI引擎 │ 知识库 │ 用户服务 │ +│ LLM调用 │ 搜索引擎│ 认证/权限 │ +├────────┴────────┴───────────────┤ +│ 数据层 │ +│ PostgreSQL + Redis + 向量数据库 │ +└─────────────────────────────────┘ +``` + +--- + +## 大模型选型汇总 + +| 功能场景 | 首选模型 | 备选模型 | 选择理由 | +|---------|---------|---------|---------| +| 图片/草图理解 | Gemini 2.5 Pro | GPT-4o、通义千问VL | 多模态识别能力最强 | +| 需求解读/结构化输出 | Claude 4 Sonnet | GPT-4o、DeepSeek-V3 | 结构化输出稳定,逻辑推理强 | +| 多轮对话追问 | Claude 4 Sonnet | GPT-4o | 上下文记忆和推理能力突出 | +| 架构推荐/技术分析 | Claude 4 Sonnet | GPT-4o、DeepSeek-V3 | 代码和架构理解能力顶级 | +| 架构图代码生成 | Claude 4 Sonnet | GPT-4o | Mermaid/PlantUML生成最稳定 | +| 分类打标签 | GPT-4o-mini | DeepSeek-V3 | 轻量任务,性价比优先 | +| 语义搜索嵌入 | text-embedding-3-large | bge-large-zh | 向量化检索,中文用bge更优 | +| RAG知识问答 | Claude 4 Sonnet | GPT-4o、DeepSeek-V3 | 检索+生成组合,回答质量高 | + +### 成本控制建议 +- **核心对话功能**用强模型(Claude 4 Sonnet / GPT-4o),保证质量 +- **辅助功能**(分类、标签、摘要)用轻量模型(GPT-4o-mini / DeepSeek-V3),降低成本 +- **国产替代方案**:DeepSeek-V3 + 通义千问VL,可大幅降低API费用,适合预算有限的情况 +- 建议后端设计**模型路由层**,根据任务类型自动分发到不同模型 + +--- + +> 本文档为功能规划文档,后续根据实际开发进展持续更新。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a21ba3 --- /dev/null +++ b/README.md @@ -0,0 +1,438 @@ +# 极码 GeekCode + +集开发者社区、AI 工具库和经验知识库为一体的综合平台,为程序员提供 AI 驱动的需求分析、架构选型、智能排版、联网搜索等工具,同时支持经验分享、API 管理、导航站等社区功能。 + +## 功能特性 + +### 1. 经验知识库(帖子系统) +- **功能说明**:用户发布技术经验帖,支持 Markdown 编辑、分类标签、点赞收藏评论、草稿保存 +- **实现方式**:`backend/routers/posts.py` 提供完整 CRUD API,支持信息流(关注/热门/最新)三种排序,自动提取首张图片作为封面(`_extract_cover_image`),热门排序算法为 `like_count * 3 + comment_count * 2 + view_count` +- **优点**:程序员可沉淀技术经验,形成团队知识资产 + +### 2. AI 智能排版 +- **功能说明**:一键将文章重新排版为结构清晰的 Markdown 格式,可选自动生成配图 +- **实现方式**:`backend/routers/ai_format.py` 调用 reasoning 类型 AI 模型排版,支持 Seedream 5.0 生成配图并上传 COS 永久存储,自动过滤 `` 思考过程 +- **优点**:保持原文不变只改格式,大幅提升文章可读性 + +### 3. 需求理解助手 +- **功能说明**:将甲方原始需求(口语、截图、文档)转化为结构化需求文档 +- **实现方式**:`backend/routers/requirement.py` 使用多模态 AI 模型,支持图片上传分析,流式输出 SSE,输出包含 MECE 功能拆解、用户故事、验收标准、复杂度预警 +- **优点**:兼具产品经理和程序员双重视角,直接产出可开发的需求文档 + +### 4. 架构选型助手 +- **功能说明**:根据功能需求输出完整技术方案,包含技术选型对比、架构图、数据模型、开发路线图 +- **实现方式**:`backend/routers/architecture.py` 流式输出,包含 Mermaid 架构图、ER 图、API 清单、工期评估 +- **优点**:基于实战经验推荐最合适的技术栈,避免过度设计 + +### 5. 联网搜索 +- **功能说明**:基于火山方舟豆包大模型的联网搜索工具,获取最新信息 +- **实现方式**:`backend/routers/web_search.py` 调用火山方舟 API 的 `web_search` 工具,搜索结果条数可配置(1-50 条),流式输出,保存对话历史 +- **优点**:获取实时信息,搜索结果经 AI 整合总结 + +### 6. 团队知识库 +- **功能说明**:从经验帖中精选内容构建团队知识库,支持分类和密码保护 +- **实现方式**:`backend/routers/knowledge_base.py` 管理员从帖子中选择添加,支持分类管理、排序、摘要生成,独立密码认证(`X-Kb-Token`) +- **优点**:沉淀高质量内容,控制知识库访问权限 + +### 7. API Hub(共享 API 管理) +- **功能说明**:团队 API 端点管理平台,支持分类、加密存储 API Key、健康检查、调用日志 +- **实现方式**:`backend/routers/shared_api.py` 提供完整管理功能,API Key 加密存储,支持 4 种认证方式(none/api_key/bearer/basic),独立密码认证(`X-Hub-Token`) +- **优点**:团队共享 API 资源,自动监控 API 可用性 + +### 8. 导航站 +- **功能说明**:开发者常用网站导航,支持用户提交和管理员审核 +- **实现方式**:`backend/routers/nav.py` 提供分类管理、链接管理、用户提交审核流程(pending → approved/rejected) +- **优点**:UGC 模式丰富导航内容,审核机制保证质量 + +### 9. 开源项目收藏 +- **功能说明**:GitHub 开源项目浏览和收藏,支持实时搜索 GitHub +- **实现方式**:`backend/routers/projects.py` 集成 GitHub API 实时搜索,管理员可批量导入,用户可收藏项目 +- **优点**:一站式发现和管理开源项目 + +### 10. 用户头像上传 +- **功能说明**:用户可上传自定义头像,存储到腾讯云 COS +- **实现方式**:前端 `Profile.vue` 点击头像触发上传,调用 `uploadApi.uploadImage` 上传到 COS,再通过 `authApi.updateProfile` 保存头像 URL +- **优点**:个性化用户形象,对象存储保证访问速度 + +### 11. 多 AI 模型管理 +- **功能说明**:后台可视化配置多个 AI 服务商和模型,按任务类型分配 +- **实现方式**:`backend/routers/ai_models.py` 支持 5 大服务商预置模型(DeepSeek、OpenAI、Anthropic、Google Gemini、火山方舟),6 种任务类型,支持测试连接 +- **优点**:灵活切换模型,按场景选择最合适的 AI 能力 + +### 12. 用户系统 +- **功能说明**:注册登录、个人资料、关注粉丝、消息通知 +- **实现方式**:JWT 认证(7 天有效期),新用户注册需管理员审核,支持封禁/解禁,实时通知(点赞、评论、关注) +- **优点**:审核机制保证社区质量 + +### 13. 后台管理 +- **功能说明**:用户管理、帖子管理、分类管理、对象存储配置、AI 模型配置、知识库管理、导航站管理、项目管理 +- **实现方式**:`backend/routers/admin.py` + 前端 `views/admin/` 10 个管理页面,仅管理员可访问 +- **优点**:完整的运营管理能力 + +## 业务流程 + +### 整体流程图 + +```mermaid +graph TB + A[用户注册] --> B{管理员审核} + B -->|通过| C[登录系统] + B -->|拒绝| A + C --> D[首页信息流] + D --> E[写文章] + D --> F[AI工具库] + D --> G[导航/项目/知识库] + E --> H[Markdown编辑] + H --> I[AI智能排版] + H --> J[上传图片/附件] + H --> K[发布/保存草稿] + F --> L[需求理解助手] + F --> M[架构选型助手] + F --> N[联网搜索] + F --> O[API Hub] + G --> P[导航站] + G --> Q[开源项目] + G --> R[团队知识库] +``` + +### 内容发布流程 + +1. 用户进入文章编辑器,选择分类和标签 +2. 使用 Markdown 编写内容,可上传图片到 COS +3. 可选使用 AI 智能排版一键优化格式 +4. 保存为草稿或直接发布 +5. 发布后自动提取首图作为封面 +6. 其他用户可点赞、收藏、评论,触发通知 + +### API 调用链路 + +| 接口路径 | 方法 | 功能 | 认证 | +|---------|------|------|------| +| /api/auth/register | POST | 用户注册 | 无 | +| /api/auth/login | POST | 用户登录 | 无 | +| /api/auth/me | GET | 获取当前用户 | JWT | +| /api/auth/profile | PUT | 更新资料/头像 | JWT | +| /api/posts | GET/POST | 帖子列表/发布 | JWT | +| /api/posts/feed | GET | 关注信息流 | JWT | +| /api/posts/hot | GET | 热门帖子 | JWT | +| /api/posts/{id} | GET/PUT/DELETE | 帖子详情/编辑/删除 | JWT | +| /api/posts/{id}/like | POST | 点赞/取消 | JWT | +| /api/posts/{id}/collect | POST | 收藏/取消 | JWT | +| /api/posts/{id}/comments | GET/POST | 评论列表/发表 | JWT | +| /api/upload/image | POST | 上传图片 | JWT | +| /api/upload/attachment | POST | 上传附件 | JWT | +| /api/ai/format | POST | AI 智能排版 | JWT | +| /api/requirement/analyze | POST | 需求分析(SSE) | JWT | +| /api/architecture/analyze | POST | 架构分析(SSE) | JWT | +| /api/web-search/search | POST | 联网搜索(SSE) | JWT | +| /api/api-hub/* | GET/POST/PUT/DELETE | API Hub 管理 | Hub Token | +| /api/kb/* | GET/POST/PUT/DELETE | 知识库管理 | Kb Token | +| /api/nav/* | GET/POST/PUT/DELETE | 导航站管理 | JWT | +| /api/projects/* | GET/POST | 项目管理 | JWT | +| /api/users/{id} | GET | 用户主页 | JWT | +| /api/users/{id}/follow | POST | 关注/取消 | JWT | +| /api/notifications | GET | 通知列表 | JWT | +| /api/search | GET | 全文搜索 | JWT | +| /api/admin/* | GET/POST/PUT/DELETE | 后台管理 | JWT + Admin | +| /api/admin/models/* | GET/POST/PUT/DELETE | AI 模型管理 | JWT + Admin | + +## 技术栈 + +| 分类 | 技术 | 版本 | +|------|------|------| +| **后端框架** | FastAPI | 0.115.0 | +| **ASGI 服务器** | Uvicorn | 0.30.0 | +| **ORM** | SQLAlchemy | 2.0.35 | +| **数据库** | MySQL | 8.0 | +| **数据库驱动** | PyMySQL | 1.1.2 | +| **JWT 认证** | python-jose | 3.3.0 | +| **密码哈希** | passlib + bcrypt | 1.7.4 / 4.0.1 | +| **HTTP 客户端** | httpx | 0.27.0 | +| **AI - OpenAI** | openai | 1.51.0 | +| **AI - Anthropic** | anthropic | 0.34.0 | +| **AI - Google** | google-generativeai | 0.8.0 | +| **对象存储** | 腾讯云 COS (cos-python-sdk-v5) | 运行时安装 | +| **图像处理** | Pillow | 10.4.0 | +| **前端框架** | Vue | 3.5.30 | +| **前端路由** | Vue Router | 4.6.4 | +| **状态管理** | Pinia | 3.0.4 | +| **HTTP 客户端** | Axios | 1.14.0 | +| **Markdown 渲染** | markdown-it | 14.1.1 | +| **代码高亮** | highlight.js | 11.11.1 | +| **图表绘制** | Mermaid | 11.13.0 | +| **CSS 框架** | Tailwind CSS | 4.2.2 | +| **构建工具** | Vite | 8.0.1 | + +## 目录结构 + +``` +BianCheng/ +├── backend/ +│ ├── main.py # FastAPI 应用入口,路由注册 +│ ├── config.py # 全局配置(数据库、JWT、AI模型) +│ ├── database.py # SQLAlchemy 连接和会话管理 +│ ├── requirements.txt # Python 依赖 +│ ├── .env # 环境变量 +│ ├── models/ # 数据库 ORM 模型(18张表) +│ │ ├── user.py # 用户 +│ │ ├── post.py # 经验帖 +│ │ ├── comment.py # 评论 +│ │ ├── like.py # 赞和收藏 +│ │ ├── follow.py # 关注关系 +│ │ ├── notification.py # 通知 +│ │ ├── ai_model.py # AI 模型配置 +│ │ ├── attachment.py # 附件 +│ │ ├── conversation.py # AI 对话 +│ │ ├── shared_api.py # 共享 API +│ │ ├── knowledge_base.py # 知识库 +│ │ ├── category.py # 文章分类 +│ │ ├── nav_category.py # 导航分类 +│ │ ├── nav_link.py # 导航链接 +│ │ ├── project.py # 开源项目 +│ │ ├── bookmark.py # 网站收藏 +│ │ └── system_config.py # 系统配置 +│ ├── routers/ # API 路由(18个模块) +│ │ ├── auth.py # 认证(注册/登录/资料) +│ │ ├── posts.py # 帖子 CRUD + 互动 +│ │ ├── users.py # 用户主页 + 关注 +│ │ ├── upload.py # 图片/附件上传(COS) +│ │ ├── ai_format.py # AI 智能排版 +│ │ ├── ai_models.py # AI 模型管理 +│ │ ├── requirement.py # 需求理解助手 +│ │ ├── architecture.py # 架构选型助手 +│ │ ├── web_search.py # 联网搜索 +│ │ ├── knowledge_base.py # 知识库 +│ │ ├── shared_api.py # API Hub +│ │ ├── nav.py # 导航站 +│ │ ├── projects.py # 开源项目 +│ │ ├── bookmarks.py # 网站收藏 +│ │ ├── notifications.py # 消息通知 +│ │ ├── search.py # 搜索 +│ │ └── admin.py # 后台管理 +│ ├── schemas/ # Pydantic 数据校验 +│ ├── services/ # 业务逻辑(AI 调用) +│ └── uploads/ # 本地上传目录(COS 降级) +├── frontend/ +│ ├── src/ +│ │ ├── main.js # Vue 应用入口 +│ │ ├── App.vue # 根组件 +│ │ ├── style.css # 全局样式 +│ │ ├── router/index.js # 路由定义(前台+后台) +│ │ ├── stores/ # Pinia 状态管理 +│ │ │ ├── user.js # 用户状态 +│ │ │ └── tabs.js # 标签页 +│ │ ├── api/ +│ │ │ ├── index.js # Axios 配置 + 拦截器 +│ │ │ └── modules.js # API 模块(20+) +│ │ └── views/ # 页面组件 +│ │ ├── Home.vue # 首页信息流 +│ │ ├── PostEditor.vue # 文章编辑器 +│ │ ├── PostDetail.vue # 文章详情 +│ │ ├── Profile.vue # 个人资料(头像上传) +│ │ ├── ToolHub.vue # 工具库 +│ │ ├── RequirementAssistant.vue # 需求助手 +│ │ ├── ArchitectureAssistant.vue # 架构助手 +│ │ ├── WebSearch.vue # 联网搜索 +│ │ ├── ApiHub.vue # API Hub +│ │ ├── KnowledgeBase.vue # 知识库 +│ │ ├── Navigation.vue # 导航站 +│ │ ├── Projects.vue # 开源项目 +│ │ └── admin/ # 后台管理(10个组件) +│ ├── package.json +│ └── vite.config.js +└── biancheng_full.sql # 数据库 SQL 转储 +``` + +## 本地开发 + +### 环境要求 +- Python 3.9+ +- Node.js 18+ +- MySQL 8.0 + +### 启动步骤 + +```bash +# 1. 克隆项目 +git clone BianCheng +cd BianCheng + +# 2. 后端 +cd backend +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +pip install cos-python-sdk-v5 # COS 对象存储(必装) + +# 3. 配置环境变量 +cp .env .env.local +# 编辑 .env 配置 DATABASE_URL 和 AI API Key + +# 4. 创建数据库 +mysql -u root -p -e "CREATE DATABASE biancheng CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + +# 5. 启动后端(自动建表) +python main.py +# 后端运行在 http://localhost:8000 + +# 6. 前端(新终端) +cd ../frontend +npm install +npm run dev +# 前端运行在 http://localhost:5173 +``` + +## 服务器部署 + +### 部署步骤 + +```bash +# 1. 本地编译前端 +cd frontend && npm run build + +# 2. 上传文件到服务器 +# - backend/ 整个目录 → /www/wwwroot/BianCheng/backend/ +# - frontend/dist/ → /www/wwwroot/BianCheng/frontend/dist/ +# ⚠️ 不要上传 venv 虚拟环境,在服务器重建 + +# 3. 服务器配置 .env(生产环境) +cat > /www/wwwroot/BianCheng/backend/.env << 'EOF' +DATABASE_URL=mysql+pymysql://biancheng:biancheng@127.0.0.1:3306/biancheng?charset=utf8mb4 +SECRET_KEY=your-production-secret-key +ARK_API_KEY=your-ark-api-key +ARK_ENDPOINT=ep-xxxxx +EOF + +# 4. 安装 Python 依赖 +cd /www/wwwroot/BianCheng/backend +pip3 install -r requirements.txt +pip3 install cos-python-sdk-v5 + +# 5. Nginx 配置要点 +# - 前端静态文件指向 frontend/dist/ +# - /api 反向代理到后端 127.0.0.1:8000 +# - client_max_body_size 20m(支持附件上传) +# - SPA 路由 try_files $uri /index.html + +# 6. 启动后端(宝塔/supervisor/systemd) +cd /www/wwwroot/BianCheng/backend +python main.py +``` + +### Nginx 配置示例 + +```nginx +server { + listen 443 ssl; + server_name your-domain.com; + + root /www/wwwroot/BianCheng/frontend/dist; + client_max_body_size 20m; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 300s; + } + + location /uploads { + proxy_pass http://127.0.0.1:8000/uploads; + } +} +``` + +## 数据库 + +### 表结构说明 + +| 分类 | 表名 | 说明 | +|------|------|------| +| **用户系统** | users | 用户信息(含审核、封禁状态) | +| **用户系统** | follows | 关注关系 | +| **用户系统** | notifications | 消息通知 | +| **内容管理** | posts | 经验帖(含草稿) | +| **内容管理** | comments | 评论 | +| **内容管理** | likes | 点赞记录 | +| **内容管理** | collects | 收藏记录 | +| **内容管理** | categories | 文章分类 | +| **内容管理** | attachments | 文件附件 | +| **AI 功能** | ai_model_configs | AI 模型配置 | +| **AI 功能** | conversations | AI 对话记录 | +| **AI 功能** | messages | 对话消息 | +| **知识库** | kb_categories | 知识库分类 | +| **知识库** | kb_items | 知识库条目 | +| **API Hub** | shared_api_categories | API 分类 | +| **API Hub** | shared_apis | 共享 API | +| **API Hub** | shared_api_logs | API 调用日志 | +| **导航站** | nav_categories | 导航分类 | +| **导航站** | nav_links | 导航链接(含审核) | +| **开源项目** | projects | 项目信息 | +| **开源项目** | project_collects | 项目收藏 | +| **其他** | bookmarks | 网站收藏 | +| **其他** | system_config | 系统配置(COS等) | + +## 环境变量 + +### backend/.env + +```bash +# 数据库(MySQL 8.0) +DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/biancheng?charset=utf8mb4 + +# JWT 密钥(生产环境必须更换) +SECRET_KEY=your-secret-key-change-in-production + +# AI 模型 API Key(至少配置一个,也可在后台模型管理中配置) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= +DEEPSEEK_API_KEY= + +# 火山方舟(豆包大模型 + 联网搜索) +ARK_API_KEY= +ARK_ENDPOINT=ep-20260411180700-z6nll +``` + +| 配置项 | 说明 | 必填 | +|-------|------|------| +| DATABASE_URL | MySQL 连接字符串,必须包含 `charset=utf8mb4` | 是 | +| SECRET_KEY | JWT 签名密钥,生产环境必须更换 | 是 | +| AI API Keys | 各服务商密钥,可在后台模型管理中配置 | 至少一个 | +| ARK_API_KEY | 火山方舟密钥(联网搜索必需) | 联网搜索需要 | +| ARK_ENDPOINT | 火山方舟模型端点 ID | 联网搜索需要 | + +### 系统运行配置(system_config 表) + +| 配置项 | 说明 | 必填 | +|-------|------|------| +| cos_secret_id | 腾讯云 COS SecretId | 图片上传需要 | +| cos_secret_key | 腾讯云 COS SecretKey | 图片上传需要 | +| cos_bucket | COS 存储桶名称 | 图片上传需要 | +| cos_region | COS 区域(如 ap-guangzhou) | 图片上传需要 | +| cos_custom_domain | CDN 自定义域名(可选) | 否 | + +## 常用命令 + +```bash +# === 开发 === +cd backend && python main.py # 启动后端 +cd frontend && npm run dev # 启动前端开发服务器 +cd frontend && npm run build # 编译前端 + +# === 数据库 === +mysql -u root -p biancheng < biancheng_full.sql # 导入数据库 +mysqldump -u root -p biancheng > backup.sql # 备份数据库 + +# === 部署 === +pip3 install -r requirements.txt # 安装后端依赖 +pip3 install cos-python-sdk-v5 # 安装 COS SDK(必装) + +# === 调试 === +curl http://localhost:8000/api/health # 健康检查 +tail -f backend/GeekCode.log # 查看后端日志 +``` diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..1039e3e --- /dev/null +++ b/backend/config.py @@ -0,0 +1,65 @@ +"""应用配置""" +import os +from dotenv import load_dotenv + +load_dotenv() + +# 数据库配置 +DATABASE_URL = os.getenv("DATABASE_URL", "mysql+pymysql://root:root@127.0.0.1:3306/biancheng?charset=utf8mb4") + +# JWT配置 +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7天 + +# 上传文件配置 +UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "uploads") +MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB +MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024 # 20MB + +# 腾讯云COS配置 +COS_SECRET_ID = os.getenv("COS_SECRET_ID", "") +COS_SECRET_KEY = os.getenv("COS_SECRET_KEY", "") +COS_BUCKET = os.getenv("COS_BUCKET", "") # 如 bianchengshequ-1250000000 +COS_REGION = os.getenv("COS_REGION", "") # 如 ap-beijing +COS_CUSTOM_DOMAIN = os.getenv("COS_CUSTOM_DOMAIN", "") # 可选,CDN自定义域名 + +# 大模型配置 +MODEL_CONFIG = { + "multimodal": { + "provider": "google", + "model": "gemini-2.5-pro-preview-06-05", + "description": "图片/草图理解", + }, + "reasoning": { + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "description": "需求解读/架构分析", + }, + "lightweight": { + "provider": "openai", + "model": "gpt-4o-mini", + "description": "分类/标签/轻量任务", + }, + "knowledge_base": { + "provider": "deepseek", + "model": "deepseek-chat", + "description": "知识库文档理解/问答", + }, + "embedding": { + "provider": "openai", + "model": "text-embedding-3-large", + "description": "向量化嵌入", + }, +} + +# API Key 配置 +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") +GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "") +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "") + +# 火山方舟(豆包大模型)配置 +ARK_API_KEY = os.getenv("ARK_API_KEY", "") +ARK_ENDPOINT = os.getenv("ARK_ENDPOINT", "ep-20260411180700-z6nll") +ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..f813abe --- /dev/null +++ b/backend/database.py @@ -0,0 +1,34 @@ +"""数据库连接配置""" +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from config import DATABASE_URL + +# 根据数据库类型配置引擎参数 +if "sqlite" in DATABASE_URL: + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +else: + engine = create_engine( + DATABASE_URL, + pool_size=10, + max_overflow=20, + pool_recycle=3600, + pool_pre_ping=True, + ) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + """获取数据库会话的依赖注入""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """初始化数据库,创建所有表""" + Base.metadata.create_all(bind=engine) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..3e6d7a0 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,174 @@ +"""FastAPI 应用入口""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +import os + +from database import init_db +from config import UPLOAD_DIR +import models.system_config # 确保 system_configs 表被创建 +import models.category # 确保 categories 表被创建 +import models.nav_category # 确保 nav_categories 表被创建 +import models.nav_link # 确保 nav_links 表被创建 +import models.project # 确保 projects 表被创建 +import models.shared_api # 确保 shared_api 相关表被创建 +import models.knowledge_base # 确保 kb 相关表被创建 +import models.attachment # 确保 attachments 表被创建 +from routers import auth, requirement, architecture, posts, search, ai_models, bookmarks, users, notifications, upload, admin, nav, projects, shared_api, knowledge_base, web_search, ai_format + +# 确保上传目录存在 +os.makedirs(UPLOAD_DIR, exist_ok=True) + +app = FastAPI( + title="极码 GeekCode", + description="极码 GeekCode - 开发者社区、AI工具库、经验知识库", + version="2.0.0", +) + +# CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 静态文件(上传的图片等) +app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") + +# 注册路由 +app.include_router(auth.router, prefix="/api/auth", tags=["认证"]) +app.include_router(requirement.router, prefix="/api/requirement", tags=["需求理解助手"]) +app.include_router(architecture.router, prefix="/api/architecture", tags=["架构选型助手"]) +app.include_router(posts.router, prefix="/api/posts", tags=["经验知识库"]) +app.include_router(search.router, prefix="/api/search", tags=["搜索"]) +app.include_router(ai_models.router) +app.include_router(ai_models.public_router) +app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["网站收藏"]) +app.include_router(users.router, prefix="/api/users", tags=["用户"]) +app.include_router(notifications.router, prefix="/api/notifications", tags=["消息通知"]) +app.include_router(upload.router, prefix="/api/upload", tags=["文件上传"]) +app.include_router(admin.router, prefix="/api/admin", tags=["后台管理"]) +app.include_router(nav.router, prefix="/api/nav", tags=["导航站"]) +app.include_router(projects.router, prefix="/api/projects", tags=["开源项目"]) +app.include_router(shared_api.router, prefix="/api/api-hub", tags=["API Hub"]) +app.include_router(knowledge_base.router, prefix="/api/kb", tags=["团队知识库"]) +app.include_router(web_search.router, prefix="/api/web-search", tags=["联网搜索"]) +app.include_router(ai_format.router) + + +@app.on_event("startup") +async def startup(): + """应用启动时初始化数据库""" + init_db() + _init_default_categories() + _migrate_user_is_approved() + _migrate_project_collect_count() + _migrate_web_search_enabled() + _migrate_web_search_count() + +def _init_default_categories(): + """如果分类表为空,插入默认分类""" + from database import SessionLocal + from models.category import Category + db = SessionLocal() + try: + if db.query(Category).count() == 0: + defaults = ['前端', '后端', '部署', '踩坑', '最佳实践', '工具'] + for i, name in enumerate(defaults): + db.add(Category(name=name, sort_order=i)) + db.commit() + finally: + db.close() + + +def _migrate_user_is_approved(): + """迁移:给 users 表添加 is_approved 字段(已有用户自动设为已审核)""" + from database import SessionLocal + from sqlalchemy import text + db = SessionLocal() + try: + # 检查字段是否存在 + result = db.execute(text( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'is_approved'" + )) + exists = result.scalar() + if not exists: + # 添加字段,先默认值1让已有用户自动通过审核 + db.execute(text("ALTER TABLE users ADD COLUMN is_approved TINYINT(1) NOT NULL DEFAULT 1")) + # 再把默认值改为0,新用户需审核 + db.execute(text("ALTER TABLE users ALTER COLUMN is_approved SET DEFAULT 0")) + db.commit() + except Exception as e: + db.rollback() + print(f"[migrate] is_approved: {e}") + finally: + db.close() + + +@app.get("/api/health") +async def health_check(): + """健康检查""" + return {"status": "ok", "message": "极码 GeekCode API 运行中"} + + +def _migrate_project_collect_count(): + """迁移:给 projects 表添加 collect_count 字段""" + from database import SessionLocal + from sqlalchemy import text + db = SessionLocal() + try: + result = db.execute(text( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'projects' AND COLUMN_NAME = 'collect_count'" + )) + if not result.scalar(): + db.execute(text("ALTER TABLE projects ADD COLUMN collect_count INT NOT NULL DEFAULT 0")) + db.commit() + except Exception as e: + db.rollback() + print(f"[migrate] project collect_count: {e}") + finally: + db.close() + + +def _migrate_web_search_enabled(): + """迁移:给 ai_model_configs 表添加 web_search_enabled 字段""" + from database import SessionLocal + from sqlalchemy import text + db = SessionLocal() + try: + result = db.execute(text( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_model_configs' AND COLUMN_NAME = 'web_search_enabled'" + )) + if not result.scalar(): + db.execute(text("ALTER TABLE ai_model_configs ADD COLUMN web_search_enabled TINYINT(1) NOT NULL DEFAULT 0")) + db.commit() + except Exception as e: + db.rollback() + print(f"[migrate] web_search_enabled: {e}") + finally: + db.close() + + +def _migrate_web_search_count(): + """迁移:给 ai_model_configs 表添加 web_search_count 字段""" + from database import SessionLocal + from sqlalchemy import text + db = SessionLocal() + try: + result = db.execute(text( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_model_configs' AND COLUMN_NAME = 'web_search_count'" + )) + if not result.scalar(): + db.execute(text("ALTER TABLE ai_model_configs ADD COLUMN web_search_count INT NOT NULL DEFAULT 5")) + db.commit() + except Exception as e: + db.rollback() + print(f"[migrate] web_search_count: {e}") + finally: + db.close() diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..f650f32 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,9 @@ +from models.user import User +from models.conversation import Conversation, Message +from models.post import Post +from models.comment import Comment +from models.like import Like, Collect +from models.ai_model import AIModelConfig +from models.bookmark import BookmarkSite +from models.follow import Follow +from models.notification import Notification diff --git a/backend/models/ai_model.py b/backend/models/ai_model.py new file mode 100644 index 0000000..9542bce --- /dev/null +++ b/backend/models/ai_model.py @@ -0,0 +1,25 @@ +"""AI模型配置模型""" +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text +from sqlalchemy.sql import func +from database import Base + + +class AIModelConfig(Base): + """AI模型配置表 - 存储各AI服务商的模型信息和API Key""" + __tablename__ = "ai_model_configs" + + id = Column(Integer, primary_key=True, index=True) + provider = Column(String(50), nullable=False) # openai/anthropic/google/deepseek + provider_name = Column(String(100), default="") # 显示名称 + model_id = Column(String(100), nullable=False) # 模型标识符 + model_name = Column(String(100), default="") # 模型显示名称 + api_key = Column(String(500), default="") # API Key + base_url = Column(String(500), default="") # 自定义API地址 + task_type = Column(String(50), default="") # 任务类型: multimodal/reasoning/lightweight/embedding + is_enabled = Column(Boolean, default=True) # 是否启用 + is_default = Column(Boolean, default=False) # 是否为该任务类型的默认模型 + web_search_enabled = Column(Boolean, default=False) # 是否启用联网搜索(仅豆包/火山方舟支持) + web_search_count = Column(Integer, default=5) # 联网搜索结果条数(1-50,默认5) + description = Column(Text, default="") # 描述说明 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/attachment.py b/backend/models/attachment.py new file mode 100644 index 0000000..20d0041 --- /dev/null +++ b/backend/models/attachment.py @@ -0,0 +1,18 @@ +"""附件模型""" +from sqlalchemy import Column, Integer, String, BigInteger, DateTime, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class Attachment(Base): + __tablename__ = "attachments" + + id = Column(Integer, primary_key=True, index=True) + post_id = Column(Integer, nullable=True, default=None, index=True) # 新建文章时为null,发布后回填 + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + filename = Column(String(255), nullable=False) # 原始文件名 + storage_key = Column(String(500), nullable=False) # COS对象键 + url = Column(String(500), nullable=False) # 完整访问URL + file_size = Column(BigInteger, nullable=False) # 文件大小(字节) + file_type = Column(String(100), nullable=False) # MIME类型 + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/bookmark.py b/backend/models/bookmark.py new file mode 100644 index 0000000..14a1cb8 --- /dev/null +++ b/backend/models/bookmark.py @@ -0,0 +1,16 @@ +"""网站收藏模型""" +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class BookmarkSite(Base): + __tablename__ = "bookmark_sites" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + name = Column(String(100), nullable=False) + url = Column(String(500), nullable=False) + icon = Column(String(500), default="") + sort_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/category.py b/backend/models/category.py new file mode 100644 index 0000000..e2ce045 --- /dev/null +++ b/backend/models/category.py @@ -0,0 +1,13 @@ +"""帖子分类模型""" +from sqlalchemy import Column, Integer, String, Boolean +from database import Base + + +class Category(Base): + """帖子分类表""" + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), unique=True, nullable=False) + sort_order = Column(Integer, default=0) # 排序,越小越靠前 + is_active = Column(Boolean, default=True) # 是否启用 diff --git a/backend/models/comment.py b/backend/models/comment.py new file mode 100644 index 0000000..8413783 --- /dev/null +++ b/backend/models/comment.py @@ -0,0 +1,14 @@ +"""评论模型""" +from sqlalchemy import Column, Integer, Text, DateTime, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class Comment(Base): + __tablename__ = "comments" + + id = Column(Integer, primary_key=True, index=True) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/conversation.py b/backend/models/conversation.py new file mode 100644 index 0000000..9264066 --- /dev/null +++ b/backend/models/conversation.py @@ -0,0 +1,33 @@ +"""对话模型 - 用于需求助手和架构助手""" +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy.sql import func +from database import Base +import enum + + +class ConversationType(str, enum.Enum): + """对话类型""" + REQUIREMENT = "requirement" # 需求理解 + ARCHITECTURE = "architecture" # 架构选型 + + +class Conversation(Base): + __tablename__ = "conversations" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + title = Column(String(200), default="新对话") + type = Column(String(20), nullable=False) # requirement / architecture + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True) + role = Column(String(20), nullable=False) # user / assistant + content = Column(Text, nullable=False) + image_urls = Column(Text, default="") # JSON数组,存储图片路径 + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/follow.py b/backend/models/follow.py new file mode 100644 index 0000000..c8dc043 --- /dev/null +++ b/backend/models/follow.py @@ -0,0 +1,17 @@ +"""关注关系模型""" +from sqlalchemy import Column, Integer, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.sql import func +from database import Base + + +class Follow(Base): + __tablename__ = "follows" + + id = Column(Integer, primary_key=True, index=True) + follower_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + following_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + UniqueConstraint("follower_id", "following_id", name="uq_follow"), + ) diff --git a/backend/models/knowledge_base.py b/backend/models/knowledge_base.py new file mode 100644 index 0000000..263d054 --- /dev/null +++ b/backend/models/knowledge_base.py @@ -0,0 +1,42 @@ +"""团队知识库模型""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class KbCategory(Base): + """知识库分类""" + __tablename__ = "kb_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False, unique=True) + icon = Column(String(500), default="") + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class KbItem(Base): + """知识库条目(关联帖子)""" + __tablename__ = "kb_items" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("kb_categories.id"), nullable=True, index=True) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False, index=True) + title = Column(String(200), nullable=False) + summary = Column(Text, default="") + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True, nullable=False) + added_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class KbAccessLog(Base): + """知识库访问日志""" + __tablename__ = "kb_access_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + action = Column(String(20), default="view") # view / search / ai_chat + query = Column(Text, default="") + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/like.py b/backend/models/like.py new file mode 100644 index 0000000..c420a87 --- /dev/null +++ b/backend/models/like.py @@ -0,0 +1,44 @@ +"""点赞/收藏模型""" +from sqlalchemy import Column, Integer, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.sql import func +from database import Base + + +class Like(Base): + __tablename__ = "likes" + + id = Column(Integer, primary_key=True, index=True) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + UniqueConstraint("post_id", "user_id", name="uq_like_post_user"), + ) + + +class Collect(Base): + __tablename__ = "collects" + + id = Column(Integer, primary_key=True, index=True) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + UniqueConstraint("post_id", "user_id", name="uq_collect_post_user"), + ) + + +class ProjectCollect(Base): + """开源项目收藏""" + __tablename__ = "project_collects" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + UniqueConstraint("project_id", "user_id", name="uq_project_collect_user"), + ) diff --git a/backend/models/nav_category.py b/backend/models/nav_category.py new file mode 100644 index 0000000..4558775 --- /dev/null +++ b/backend/models/nav_category.py @@ -0,0 +1,15 @@ +"""导航分类模型""" +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from database import Base + + +class NavCategory(Base): + __tablename__ = "nav_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + icon = Column(String(500), default="") + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/nav_link.py b/backend/models/nav_link.py new file mode 100644 index 0000000..77d8f77 --- /dev/null +++ b/backend/models/nav_link.py @@ -0,0 +1,22 @@ +"""导航链接模型""" +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class NavLink(Base): + __tablename__ = "nav_links" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("nav_categories.id"), nullable=False, index=True) + name = Column(String(100), nullable=False) + url = Column(String(500), nullable=False) + icon = Column(String(500), default="") + description = Column(String(200), default="") + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True, nullable=False) + # 审核相关: approved=已通过, pending=待审核, rejected=已拒绝 + status = Column(String(20), default="approved", nullable=False, index=True) + submitted_by = Column(Integer, ForeignKey("users.id"), nullable=True) + reject_reason = Column(String(200), default="") + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/notification.py b/backend/models/notification.py new file mode 100644 index 0000000..05b4360 --- /dev/null +++ b/backend/models/notification.py @@ -0,0 +1,17 @@ +"""消息通知模型""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + type = Column(String(20), nullable=False) # like / comment / follow / system + content = Column(Text, default="") + related_id = Column(Integer, default=None) # 关联的帖子/用户ID + from_user_id = Column(Integer, ForeignKey("users.id"), default=None) # 触发通知的用户 + is_read = Column(Boolean, default=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/post.py b/backend/models/post.py new file mode 100644 index 0000000..97afeec --- /dev/null +++ b/backend/models/post.py @@ -0,0 +1,23 @@ +"""经验帖模型""" +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class Post(Base): + __tablename__ = "posts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + title = Column(String(200), nullable=False) + content = Column(Text, nullable=False) + category = Column(String(50), default="") # 分类:前端/后端/部署/踩坑/最佳实践 + tags = Column(Text, default="") # JSON数组存储标签 + is_public = Column(Boolean, default=True) + is_draft = Column(Boolean, default=False, index=True) # 草稿状态 + view_count = Column(Integer, default=0) + like_count = Column(Integer, default=0) + collect_count = Column(Integer, default=0) + comment_count = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/project.py b/backend/models/project.py new file mode 100644 index 0000000..0c2c8ab --- /dev/null +++ b/backend/models/project.py @@ -0,0 +1,24 @@ +"""开源项目模型""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime +from sqlalchemy.sql import func +from database import Base + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, default="") + url = Column(String(500), nullable=False) + homepage = Column(String(500), default="") + icon = Column(String(500), default="") + language = Column(String(50), default="") + category = Column(String(50), default="", index=True) + stars = Column(Integer, default=0) + forks = Column(Integer, default=0) + collect_count = Column(Integer, default=0) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/shared_api.py b/backend/models/shared_api.py new file mode 100644 index 0000000..e7accdd --- /dev/null +++ b/backend/models/shared_api.py @@ -0,0 +1,59 @@ +"""共享API管理模型""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from database import Base + + +class SharedApiCategory(Base): + """API分类""" + __tablename__ = "shared_api_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False, unique=True) + icon = Column(String(500), default="") + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class SharedApi(Base): + """共享API""" + __tablename__ = "shared_apis" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("shared_api_categories.id"), nullable=True, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, default="") + base_url = Column(String(500), nullable=False) + doc_url = Column(String(500), default="") + # 认证方式: none / api_key / bearer / basic + auth_type = Column(String(20), default="none") + # 加密存储的API Key + api_key_encrypted = Column(Text, default="") + # API Key 放在哪个请求头中, 如 Authorization, X-API-Key + api_key_header = Column(String(100), default="Authorization") + # 健康检查 + health_check_url = Column(String(500), default="") + last_check_time = Column(DateTime(timezone=True), nullable=True) + last_check_status = Column(String(20), default="unknown") # ok / error / unknown + # 元信息 + added_by = Column(Integer, ForeignKey("users.id"), nullable=True) + tags = Column(String(500), default="") # 逗号分隔 + call_count = Column(Integer, default=0) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class SharedApiLog(Base): + """API使用日志""" + __tablename__ = "shared_api_logs" + + id = Column(Integer, primary_key=True, index=True) + api_id = Column(Integer, ForeignKey("shared_apis.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + action = Column(String(20), default="test") # test / health_check + request_url = Column(String(500), default="") + response_status = Column(Integer, nullable=True) + response_time_ms = Column(Integer, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/models/system_config.py b/backend/models/system_config.py new file mode 100644 index 0000000..a86ab75 --- /dev/null +++ b/backend/models/system_config.py @@ -0,0 +1,15 @@ +"""系统配置模型 - 键值对存储""" +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import func +from database import Base + + +class SystemConfig(Base): + """系统配置表 - 存储OSS等系统级配置""" + __tablename__ = "system_configs" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(100), unique=True, index=True, nullable=False) + value = Column(Text, default="") + description = Column(String(200), default="") + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..b410962 --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,18 @@ +"""用户模型""" +from sqlalchemy import Column, Integer, String, DateTime, Boolean +from sqlalchemy.sql import func +from database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(100), unique=True, index=True, nullable=False) + password_hash = Column(String(200), nullable=False) + avatar = Column(String(500), default="") + is_admin = Column(Boolean, default=False, nullable=False) + is_banned = Column(Boolean, default=False, nullable=False) + is_approved = Column(Boolean, default=False, nullable=False) # 新用户需管理员审核 + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7a74d44 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,18 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.35 +alembic==1.13.0 +python-multipart==0.0.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +openai==1.51.0 +anthropic==0.34.0 +google-generativeai==0.8.0 +httpx==0.27.0 +python-dotenv==1.0.1 +aiofiles==24.1.0 +Pillow==10.4.0 +markdown-it-py==3.0.0 +oss2==2.19.1 +pymysql==1.1.2 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..d05c51b --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,458 @@ +"""后台管理路由""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func, distinct +from datetime import datetime, timedelta +from pydantic import BaseModel +from typing import Optional + +from database import get_db +from models.user import User +from models.post import Post +from models.comment import Comment +from models.like import Like, Collect +from models.system_config import SystemConfig +from models.category import Category +from routers.auth import get_admin_user, get_current_user + +router = APIRouter() + +# ---------- 对象存储配置管理(腾讯云COS) ---------- + +COS_CONFIG_KEYS = [ + {"key": "cos_secret_id", "description": "SecretId"}, + {"key": "cos_secret_key", "description": "SecretKey"}, + {"key": "cos_bucket", "description": "Bucket(如 bianchengshequ-1250000000)"}, + {"key": "cos_region", "description": "Region(如 ap-beijing)"}, + {"key": "cos_custom_domain", "description": "自定义域名(可选,CDN加速域名)"}, +] + + +def get_cos_config_from_db(db: Session) -> dict: + """从数据库读取COS配置""" + config = {} + for item in COS_CONFIG_KEYS: + row = db.query(SystemConfig).filter(SystemConfig.key == item["key"]).first() + config[item["key"]] = row.value if row else "" + return config + + +class CosConfigUpdate(BaseModel): + cos_secret_id: str = "" + cos_secret_key: Optional[str] = None + cos_bucket: str = "" + cos_region: str = "" + cos_custom_domain: str = "" + + +@router.get("/storage/config") +async def get_storage_config( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """获取对象存储配置""" + config = get_cos_config_from_db(db) + # 脱敏 SecretKey + secret = config.get("cos_secret_key", "") + if secret and len(secret) > 6: + config["cos_secret_key_masked"] = secret[:3] + "*" * (len(secret) - 6) + secret[-3:] + else: + config["cos_secret_key_masked"] = "*" * len(secret) if secret else "" + config.pop("cos_secret_key", None) + return {"config": config, "fields": COS_CONFIG_KEYS} + + +@router.put("/storage/config") +async def update_storage_config( + data: CosConfigUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """更新对象存储配置""" + updates = data.dict(exclude_none=True) + for key, value in updates.items(): + row = db.query(SystemConfig).filter(SystemConfig.key == key).first() + if row: + row.value = value + else: + desc = next((i["description"] for i in COS_CONFIG_KEYS if i["key"] == key), "") + db.add(SystemConfig(key=key, value=value, description=desc)) + db.commit() + return {"message": "配置已保存"} + + +@router.post("/storage/test") +async def test_storage_connection( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """测试COS连接""" + config = get_cos_config_from_db(db) + secret_id = config.get("cos_secret_id", "") + secret_key = config.get("cos_secret_key", "") + bucket = config.get("cos_bucket", "") + region = config.get("cos_region", "") + + if not all([secret_id, secret_key, bucket, region]): + raise HTTPException(status_code=400, detail="COS配置不完整,请先填写所有必填项") + + try: + from qcloud_cos import CosConfig, CosS3Client + cos_config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key) + client = CosS3Client(cos_config) + # 尝试获取bucket信息来验证连接 + client.head_bucket(Bucket=bucket) + return {"success": True, "message": "连接成功"} + except ImportError: + raise HTTPException(status_code=500, detail="服务器未安装 cos-python-sdk-v5 库,请执行 pip install cos-python-sdk-v5") + except Exception as e: + raise HTTPException(status_code=400, detail=f"连接失败: {str(e)}") + + +@router.get("/stats") +async def get_stats( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """获取管理后台统计数据""" + today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + + # 基础统计 + total_users = db.query(sa_func.count(User.id)).scalar() or 0 + total_posts = db.query(sa_func.count(Post.id)).scalar() or 0 + total_comments = db.query(sa_func.count(Comment.id)).scalar() or 0 + total_likes = db.query(sa_func.count(Like.id)).scalar() or 0 + + # 今日新增 + today_users = db.query(sa_func.count(User.id)).filter(User.created_at >= today).scalar() or 0 + today_posts = db.query(sa_func.count(Post.id)).filter(Post.created_at >= today).scalar() or 0 + + # 今日活跃(今日有发帖/评论/点赞行为的用户) + active_post = db.query(distinct(Post.user_id)).filter(Post.created_at >= today) + active_comment = db.query(distinct(Comment.user_id)).filter(Comment.created_at >= today) + active_like = db.query(distinct(Like.user_id)).filter(Like.created_at >= today) + active_ids = set() + for row in active_post.all(): + active_ids.add(row[0]) + for row in active_comment.all(): + active_ids.add(row[0]) + for row in active_like.all(): + active_ids.add(row[0]) + today_active = len(active_ids) + + # 7日趋势 + user_trend = [] + post_trend = [] + for i in range(6, -1, -1): + day_start = today - timedelta(days=i) + day_end = day_start + timedelta(days=1) + date_str = day_start.strftime("%m-%d") + + u_count = db.query(sa_func.count(User.id)).filter( + User.created_at >= day_start, User.created_at < day_end + ).scalar() or 0 + p_count = db.query(sa_func.count(Post.id)).filter( + Post.created_at >= day_start, Post.created_at < day_end + ).scalar() or 0 + + user_trend.append({"date": date_str, "count": u_count}) + post_trend.append({"date": date_str, "count": p_count}) + + # 最近注册用户 + recent_users = db.query(User).order_by(User.created_at.desc()).limit(5).all() + recent_users_data = [ + {"id": u.id, "username": u.username, "email": u.email, "created_at": str(u.created_at)} + for u in recent_users + ] + + # 最近发布帖子 + recent_posts = db.query(Post).order_by(Post.created_at.desc()).limit(5).all() + recent_posts_data = [] + for p in recent_posts: + author = db.query(User).filter(User.id == p.user_id).first() + recent_posts_data.append({ + "id": p.id, "title": p.title, + "author": author.username if author else "未知", + "created_at": str(p.created_at), + }) + + return { + "total_users": total_users, + "total_posts": total_posts, + "total_comments": total_comments, + "total_likes": total_likes, + "today_users": today_users, + "today_posts": today_posts, + "today_active": today_active, + "user_trend": user_trend, + "post_trend": post_trend, + "recent_users": recent_users_data, + "recent_posts": recent_posts_data, + } + + +@router.get("/users") +async def list_users( + search: str = "", + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """用户管理列表""" + query = db.query(User) + if search: + query = query.filter(User.username.contains(search)) + + total = query.count() + users = query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + items = [] + for u in users: + post_count = db.query(sa_func.count(Post.id)).filter(Post.user_id == u.id).scalar() or 0 + comment_count = db.query(sa_func.count(Comment.id)).filter(Comment.user_id == u.id).scalar() or 0 + items.append({ + "id": u.id, + "username": u.username, + "email": u.email, + "avatar": u.avatar or "", + "is_admin": u.is_admin, + "is_banned": getattr(u, 'is_banned', False), + "is_approved": getattr(u, 'is_approved', True), + "post_count": post_count, + "comment_count": comment_count, + "created_at": str(u.created_at), + }) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +@router.put("/users/{user_id}/toggle-admin") +async def toggle_admin( + user_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """切换用户管理员身份""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="不能修改自己的管理员状态") + user.is_admin = not user.is_admin + db.commit() + return {"message": f"已{'设为' if user.is_admin else '取消'}管理员", "is_admin": user.is_admin} + + +@router.put("/users/{user_id}/toggle-ban") +async def toggle_ban( + user_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """封禁/解封用户""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="不能封禁自己") + if user.is_admin: + raise HTTPException(status_code=400, detail="不能封禁管理员") + user.is_banned = not user.is_banned + db.commit() + return {"message": f"已{'封禁' if user.is_banned else '解封'}该用户", "is_banned": user.is_banned} + + +@router.put("/users/{user_id}/approve") +async def approve_user( + user_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """审核通过用户""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if getattr(user, 'is_approved', False): + raise HTTPException(status_code=400, detail="该用户已通过审核") + user.is_approved = True + db.commit() + return {"message": f"已审核通过用户:{user.username}", "is_approved": True} + + +@router.put("/users/{user_id}/reject") +async def reject_user( + user_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """拒绝/撤回用户审核""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.is_admin: + raise HTTPException(status_code=400, detail="不能拒绝管理员") + user.is_approved = False + db.commit() + return {"message": f"已拒绝用户:{user.username}", "is_approved": False} + + +@router.get("/posts") +async def list_posts( + search: str = "", + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """帖子管理列表""" + query = db.query(Post) + if search: + query = query.filter(Post.title.contains(search)) + + total = query.count() + posts = query.order_by(Post.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + items = [] + for p in posts: + author = db.query(User).filter(User.id == p.user_id).first() + like_count = db.query(sa_func.count(Like.id)).filter(Like.post_id == p.id).scalar() or 0 + comment_count = db.query(sa_func.count(Comment.id)).filter(Comment.post_id == p.id).scalar() or 0 + items.append({ + "id": p.id, + "title": p.title, + "author": author.username if author else "未知", + "author_id": p.user_id, + "category": p.category or "", + "is_public": p.is_public, + "like_count": like_count, + "comment_count": comment_count, + "view_count": p.view_count, + "created_at": str(p.created_at), + }) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +@router.delete("/posts/{post_id}") +async def delete_post( + post_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """管理员删除帖子""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在") + # 删除关联数据 + db.query(Comment).filter(Comment.post_id == post_id).delete() + db.query(Like).filter(Like.post_id == post_id).delete() + db.query(Collect).filter(Collect.post_id == post_id).delete() + db.delete(post) + db.commit() + return {"message": "删除成功"} + + +# ---------- 分类管理 ---------- + +@router.get("/categories") +async def list_categories( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """获取所有分类(含禁用的)""" + cats = db.query(Category).order_by(Category.sort_order, Category.id).all() + return [{"id": c.id, "name": c.name, "sort_order": c.sort_order, "is_active": c.is_active} for c in cats] + + +class CategoryCreate(BaseModel): + name: str + +class CategoryUpdate(BaseModel): + name: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +@router.post("/categories") +async def create_category( + data: CategoryCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """新增分类""" + existing = db.query(Category).filter(Category.name == data.name).first() + if existing: + raise HTTPException(status_code=400, detail="分类名称已存在") + max_order = db.query(sa_func.max(Category.sort_order)).scalar() or 0 + cat = Category(name=data.name, sort_order=max_order + 1) + db.add(cat) + db.commit() + db.refresh(cat) + return {"id": cat.id, "name": cat.name, "sort_order": cat.sort_order, "is_active": cat.is_active} + + +@router.put("/categories/{cat_id}") +async def update_category( + cat_id: int, + data: CategoryUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """修改分类""" + cat = db.query(Category).filter(Category.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + if data.name is not None: + dup = db.query(Category).filter(Category.name == data.name, Category.id != cat_id).first() + if dup: + raise HTTPException(status_code=400, detail="分类名称已存在") + cat.name = data.name + if data.sort_order is not None: + cat.sort_order = data.sort_order + if data.is_active is not None: + cat.is_active = data.is_active + db.commit() + return {"id": cat.id, "name": cat.name, "sort_order": cat.sort_order, "is_active": cat.is_active} + + +@router.delete("/categories/{cat_id}") +async def delete_category( + cat_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """删除分类""" + cat = db.query(Category).filter(Category.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + db.delete(cat) + db.commit() + return {"message": "删除成功"} + + +@router.put("/categories/reorder") +async def reorder_categories( + items: list, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """批量更新分类排序""" + for item in items: + cat = db.query(Category).filter(Category.id == item["id"]).first() + if cat: + cat.sort_order = item["sort_order"] + db.commit() + return {"message": "排序已更新"} + + +# ---------- 公开分类API(无需管理员权限) ---------- + +@router.get("/public/categories") +async def get_public_categories( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取启用的分类列表(前台使用)""" + cats = db.query(Category).filter(Category.is_active == True).order_by(Category.sort_order, Category.id).all() + return [c.name for c in cats] diff --git a/backend/routers/ai_format.py b/backend/routers/ai_format.py new file mode 100644 index 0000000..44240d5 --- /dev/null +++ b/backend/routers/ai_format.py @@ -0,0 +1,223 @@ +"""AI智能排版路由 - 文本排版 + 自动配图""" +import json +import uuid +import httpx +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from database import get_db +from models.user import User +from models.ai_model import AIModelConfig +from routers.auth import get_current_user +from services.ai_service import ai_service + +router = APIRouter(prefix="/api/ai", tags=["AI智能排版"]) + +FORMAT_SYSTEM_PROMPT = """你是一位专业的文章排版编辑。你的任务是将用户提供的文章内容重新排版为结构清晰、可读性强的 Markdown 格式。 + +【核心原则】绝对不能修改、删除、改写或替换原文的任何文字内容。原文的每一个字、每一句话都必须原样保留。你可以在合适的位置补充过渡语、小结或说明文字来增强可读性,但必须与原文明确区分,且不能改动原文已有的文字。 + +排版规则: +1. 分析文章结构,在合适位置添加标题层级(## 和 ###),标题文字从原文中提取 +2. 将原文中的要点整理为列表(有序或无序),但列表内容必须是原文原句 +3. 重要观点用引用块 > 包裹,引用内容必须是原文原句 +4. 关键词和重要内容用 **加粗** 标记 +5. 保留原文中所有图片链接(![...](...)格式),不要修改或删除 +6. 保留原文中所有URL链接,转为 [链接文字](url) 格式 +7. 适当添加分隔线 --- 划分章节 +8. 长段落拆分为短段落,提高可读性,但不能改变段落中的文字 +9. 如果内容中有流程或步骤,用有序列表清晰展示 +10. 不要添加原文中没有的文字、解释、总结或过渡语 + +同时,你需要分析文章内容,为文章建议 1-2 张配图。对于每张配图,在合适的位置插入占位符,格式为: +[AI_IMAGE: 图片描述prompt,用英文写,描述要生成的图片内容,风格简洁专业] + +注意: +- 占位符要插在文章逻辑合适的位置(如章节开头、流程说明旁边) +- prompt 用英文描述,风格:flat illustration, modern, professional, tech style +- 不要超过 2 个图片占位符 +- 如果文章内容不适合配图(如纯代码、纯链接列表),可以不加占位符""" + + +class FormatRequest(BaseModel): + model_config = {"protected_namespaces": ()} + content: str + generate_images: bool = False # 是否生成配图(默认关闭) + model_config_id: Optional[int] = None # 指定排版用的文本模型 + + +class FormatResponse(BaseModel): + formatted_content: str + images_generated: int = 0 + + +def _get_image_model(db: Session): + """从数据库查找已配置的图像生成模型(Seedream endpoint)""" + # 查找 task_type 包含 image 或 model_name 包含 seedream 的模型 + model = db.query(AIModelConfig).filter( + AIModelConfig.is_enabled == True, + AIModelConfig.task_type == "image", + ).first() + if model: + return { + "api_key": model.api_key, + "base_url": model.base_url or "https://ark.cn-beijing.volces.com/api/v3", + "model_id": model.model_id, + } + return None + + +async def _generate_image(api_key: str, base_url: str, model_id: str, prompt: str) -> Optional[bytes]: + """调用火山方舟 Seedream 生成图片,返回图片二进制数据""" + url = f"{base_url.rstrip('/')}/images/generations" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "model": model_id, + "prompt": prompt, + "response_format": "url", + "size": "1920x1080", # 满足 Seedream 5.0 最低像素要求 + } + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code != 200: + print(f"图像生成失败: {resp.status_code} - {resp.text}") + return None + data = resp.json() + image_url = data.get("data", [{}])[0].get("url", "") + if not image_url: + return None + # 下载图片(因为 Seedream 返回的是临时链接) + img_resp = await client.get(image_url) + if img_resp.status_code == 200: + return img_resp.content + return None + except Exception as e: + print(f"图像生成异常: {e}") + return None + + +def _upload_to_cos(db: Session, image_data: bytes) -> Optional[str]: + """将图片上传到 COS,返回永久 URL""" + from models.system_config import SystemConfig + keys = ["cos_secret_id", "cos_secret_key", "cos_bucket", "cos_region", "cos_custom_domain"] + config = {} + for k in keys: + row = db.query(SystemConfig).filter(SystemConfig.key == k).first() + config[k] = row.value if row else "" + + secret_id = config.get("cos_secret_id", "") + secret_key = config.get("cos_secret_key", "") + bucket = config.get("cos_bucket", "") + region = config.get("cos_region", "") + + if not all([secret_id, secret_key, bucket, region]): + return None + + try: + from qcloud_cos import CosConfig, CosS3Client + cos_config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key) + client = CosS3Client(cos_config) + + date_prefix = datetime.now().strftime("%Y/%m") + filename = f"{uuid.uuid4().hex}.png" + object_key = f"images/{date_prefix}/{filename}" + + client.put_object( + Bucket=bucket, + Body=image_data, + Key=object_key, + ContentType="image/png", + ) + + custom_domain = config.get("cos_custom_domain", "") + if custom_domain: + return f"https://{custom_domain}/{object_key}" + return f"https://{bucket}.cos.{region}.myqcloud.com/{object_key}" + except Exception as e: + print(f"COS上传失败: {e}") + return None + + +@router.post("/format", response_model=FormatResponse) +async def format_article( + req: FormatRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """AI智能排版:格式化文章 + 自动生成配图""" + if not req.content.strip(): + raise HTTPException(status_code=400, detail="文章内容不能为空") + + # 第1步:AI 排版文本 + messages = [{"role": "user", "content": req.content}] + try: + formatted = await ai_service.chat( + task_type="reasoning", + messages=messages, + system_prompt=FORMAT_SYSTEM_PROMPT, + stream=False, + model_config_id=req.model_config_id, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"AI排版失败: {str(e)}") + + if not isinstance(formatted, str): + raise HTTPException(status_code=500, detail="AI排版返回格式异常") + + # 过滤掉思考过程(DeepSeek Reasoner 会输出 ...) + import re as _re + formatted = _re.sub(r'[\s\S]*?\s*', '', formatted).strip() + # 也过滤
格式的思考过程 + formatted = _re.sub(r'
[\s\S]*?
\s*', '', formatted).strip() + + images_generated = 0 + + # 第2步:生成配图(如果启用) + if req.generate_images: + import re + placeholders = re.findall(r'\[AI_IMAGE:\s*(.+?)\]', formatted) + + if placeholders: + image_model = _get_image_model(db) + if image_model: + for prompt in placeholders: + image_data = await _generate_image( + image_model["api_key"], + image_model["base_url"], + image_model["model_id"], + prompt.strip(), + ) + if image_data: + # 上传到 COS + cos_url = _upload_to_cos(db, image_data) + if cos_url: + formatted = formatted.replace( + f"[AI_IMAGE: {prompt}]", + f"![{prompt.strip()[:50]}]({cos_url})", + 1, + ) + images_generated += 1 + continue + # 生成或上传失败,移除占位符 + formatted = formatted.replace(f"[AI_IMAGE: {prompt}]", "", 1) + else: + # 没有配置图像模型,清理所有占位符 + formatted = re.sub(r'\[AI_IMAGE:\s*.+?\]', '', formatted) + + # 清理可能残留的占位符 + import re + formatted = re.sub(r'\[AI_IMAGE:\s*.+?\]', '', formatted) + # 清理多余空行 + formatted = re.sub(r'\n{3,}', '\n\n', formatted).strip() + + return FormatResponse( + formatted_content=formatted, + images_generated=images_generated, + ) diff --git a/backend/routers/ai_models.py b/backend/routers/ai_models.py new file mode 100644 index 0000000..690a079 --- /dev/null +++ b/backend/routers/ai_models.py @@ -0,0 +1,285 @@ +"""AI模型管理路由""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from database import get_db +from models.ai_model import AIModelConfig +from models.user import User +from schemas.ai_model import AIModelCreate, AIModelUpdate, AIModelResponse, ProviderInfo +from routers.auth import get_admin_user, get_current_user + +router = APIRouter(prefix="/api/admin/models", tags=["AI模型管理"]) + +# 公开路由(登录用户可用,用于前台 AI 工具获取可选模型) +public_router = APIRouter(prefix="/api/models", tags=["AI模型公开"]) + +# 预置的服务商和模型信息 +PROVIDER_PRESETS = [ + { + "provider": "deepseek", + "name": "DeepSeek", + "default_base_url": "https://api.deepseek.com", + "models": [ + {"model_id": "deepseek-chat", "name": "DeepSeek-V3.2", "task_types": ["lightweight", "knowledge_base"], "description": "DeepSeek-V3.2 非思考模式,性价比极高"}, + {"model_id": "deepseek-reasoner", "name": "DeepSeek-V3.2 思考", "task_types": ["reasoning", "knowledge_base"], "description": "DeepSeek-V3.2 思考模式,带推理链输出"}, + ] + }, + { + "provider": "openai", + "name": "OpenAI", + "default_base_url": "https://api.openai.com/v1", + "models": [ + {"model_id": "gpt-4o", "name": "GPT-4o", "task_types": ["multimodal", "reasoning"], "description": "多模态旗舰模型"}, + {"model_id": "gpt-4o-mini", "name": "GPT-4o Mini", "task_types": ["lightweight"], "description": "轻量高效模型"}, + {"model_id": "o3-mini", "name": "o3-mini", "task_types": ["reasoning"], "description": "推理增强模型"}, + {"model_id": "text-embedding-3-large", "name": "Embedding Large", "task_types": ["embedding"], "description": "高维度文本嵌入模型"}, + ] + }, + { + "provider": "anthropic", + "name": "Anthropic", + "default_base_url": "https://api.anthropic.com", + "models": [ + {"model_id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4", "task_types": ["reasoning"], "description": "Claude最新推理模型"}, + {"model_id": "claude-3-5-haiku-20241022", "name": "Claude 3.5 Haiku", "task_types": ["lightweight"], "description": "快速轻量模型"}, + ] + }, + { + "provider": "google", + "name": "Google Gemini", + "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "models": [ + {"model_id": "gemini-2.5-pro-preview-06-05", "name": "Gemini 2.5 Pro", "task_types": ["multimodal", "reasoning"], "description": "多模态能力最强"}, + {"model_id": "gemini-2.0-flash", "name": "Gemini 2.0 Flash", "task_types": ["lightweight", "multimodal"], "description": "快速多模态模型"}, + ] + }, + { + "provider": "ark", + "name": "火山方舟(豆包)", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "models": [ + {"model_id": "ep-20260411180700-z6nll", "name": "Doubao-Seed-2.0-pro", "task_types": ["reasoning", "lightweight", "knowledge_base"], "description": "豆包旗舰模型,支持联网搜索"}, + {"model_id": "doubao-seedream-5-0-260128", "name": "Seedream 5.0 (图像生成)", "task_types": ["image"], "description": "豆包图像生成模型,支持文生图"}, + ] + }, +] + +TASK_TYPE_LABELS = { + "multimodal": "多模态(图片/草图理解)", + "reasoning": "推理分析(需求解读/架构分析)", + "lightweight": "轻量任务(分类/标签)", + "knowledge_base": "知识库分析(文档理解/问答)", + "embedding": "向量嵌入", + "image": "图像生成(AI配图/文生图)", +} + + +def _mask_api_key(key: str) -> str: + """API Key脱敏""" + if not key or len(key) < 8: + return "****" if key else "" + return key[:4] + "*" * (len(key) - 8) + key[-4:] + + +def _to_response(model: AIModelConfig) -> dict: + """转换为响应格式""" + return { + "id": model.id, + "provider": model.provider, + "provider_name": model.provider_name, + "model_id": model.model_id, + "model_name": model.model_name, + "api_key_masked": _mask_api_key(model.api_key), + "base_url": model.base_url, + "task_type": model.task_type, + "is_enabled": model.is_enabled, + "is_default": model.is_default, + "web_search_enabled": model.web_search_enabled, + "description": model.description, + "created_at": model.created_at, + "updated_at": model.updated_at, + } + + +@router.get("/presets", response_model=List[ProviderInfo]) +async def get_provider_presets(): + """获取预置的服务商和模型列表""" + return PROVIDER_PRESETS + + +@router.get("/task-types") +async def get_task_types(): + """获取任务类型列表""" + return TASK_TYPE_LABELS + + +@router.get("", response_model=List[AIModelResponse]) +async def list_models(provider: str = None, task_type: str = None, db: Session = Depends(get_db)): + """获取所有已配置的模型""" + query = db.query(AIModelConfig) + if provider: + query = query.filter(AIModelConfig.provider == provider) + if task_type: + query = query.filter(AIModelConfig.task_type == task_type) + models = query.order_by(AIModelConfig.provider, AIModelConfig.created_at).all() + return [_to_response(m) for m in models] + + +@router.post("", response_model=AIModelResponse) +async def create_model(data: AIModelCreate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + """添加模型配置""" + model = AIModelConfig( + provider=data.provider, + provider_name=data.provider_name, + model_id=data.model_id, + model_name=data.model_name, + api_key=data.api_key, + base_url=data.base_url, + task_type=data.task_type, + is_enabled=data.is_enabled, + is_default=data.is_default, + web_search_enabled=data.web_search_enabled, + description=data.description, + ) + # 如果设为默认,取消同任务类型的其他默认 + if data.is_default and data.task_type: + db.query(AIModelConfig).filter( + AIModelConfig.task_type == data.task_type, + AIModelConfig.is_default == True + ).update({"is_default": False}) + db.add(model) + db.commit() + db.refresh(model) + return _to_response(model) + + +@router.put("/{model_id}", response_model=AIModelResponse) +async def update_model(model_id: int, data: AIModelUpdate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + """更新模型配置""" + model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() + if not model: + raise HTTPException(status_code=404, detail="模型配置不存在") + + update_data = data.dict(exclude_unset=True) + + # 如果API Key为空字符串,表示不修改 + if "api_key" in update_data and update_data["api_key"] == "": + del update_data["api_key"] + + # 如果设为默认,取消同任务类型的其他默认 + if update_data.get("is_default") and (update_data.get("task_type") or model.task_type): + task = update_data.get("task_type", model.task_type) + db.query(AIModelConfig).filter( + AIModelConfig.task_type == task, + AIModelConfig.is_default == True, + AIModelConfig.id != model_id + ).update({"is_default": False}) + + for key, value in update_data.items(): + setattr(model, key, value) + db.commit() + db.refresh(model) + return _to_response(model) + + +@router.delete("/{model_id}") +async def delete_model(model_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + """删除模型配置""" + model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() + if not model: + raise HTTPException(status_code=404, detail="模型配置不存在") + db.delete(model) + db.commit() + return {"message": "删除成功"} + + +@router.post("/init-defaults") +async def init_default_models(db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + """初始化默认模型配置(仅当数据库为空时)""" + count = db.query(AIModelConfig).count() + if count > 0: + return {"message": f"已有 {count} 条配置,跳过初始化", "count": count} + + defaults = [ + AIModelConfig(provider="deepseek", provider_name="DeepSeek", model_id="deepseek-chat", + model_name="DeepSeek-V3", task_type="reasoning", is_default=True, is_enabled=True, + base_url="https://api.deepseek.com/v1", description="DeepSeek最新对话模型,性价比极高"), + AIModelConfig(provider="deepseek", provider_name="DeepSeek", model_id="deepseek-reasoner", + model_name="DeepSeek-R1", task_type="", is_enabled=True, + base_url="https://api.deepseek.com/v1", description="深度推理模型,适合复杂逻辑分析"), + AIModelConfig(provider="openai", provider_name="OpenAI", model_id="gpt-4o-mini", + model_name="GPT-4o Mini", task_type="lightweight", is_default=True, is_enabled=True, + description="轻量高效模型"), + AIModelConfig(provider="google", provider_name="Google Gemini", model_id="gemini-2.5-pro-preview-06-05", + model_name="Gemini 2.5 Pro", task_type="multimodal", is_default=True, is_enabled=True, + base_url="https://generativelanguage.googleapis.com/v1beta/openai", + description="多模态能力最强"), + ] + db.add_all(defaults) + db.commit() + return {"message": f"已初始化 {len(defaults)} 条默认配置", "count": len(defaults)} + + +@router.post("/{model_id}/test") +async def test_model_connection(model_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + """测试模型连接是否正常""" + model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first() + if not model: + raise HTTPException(status_code=404, detail="模型配置不存在") + if not model.api_key: + return {"success": False, "message": "未配置 API Key"} + + try: + import httpx + headers = {"Authorization": f"Bearer {model.api_key}", "Content-Type": "application/json"} + base_url = model.base_url or f"https://api.{model.provider}.com/v1" + + if model.provider == "anthropic": + headers = {"x-api-key": model.api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"} + url = f"{base_url}/v1/messages" + payload = {"model": model.model_id, "max_tokens": 10, "messages": [{"role": "user", "content": "hi"}]} + else: + url = f"{base_url}/chat/completions" + payload = {"model": model.model_id, "max_tokens": 10, "messages": [{"role": "user", "content": "hi"}]} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code == 200: + return {"success": True, "message": "连接成功"} + else: + return {"success": False, "message": f"HTTP {resp.status_code}: {resp.text[:200]}"} + except Exception as e: + return {"success": False, "message": f"连接失败: {str(e)}"} + + +# ===== 公开接口 ===== + +@public_router.get("/available") +async def get_available_models( + task_type: str = "", + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取可用模型列表(登录用户可调用,不返回 API Key)""" + query = db.query(AIModelConfig).filter( + AIModelConfig.is_enabled == True, + AIModelConfig.api_key != "", + ) + if task_type: + query = query.filter(AIModelConfig.task_type == task_type) + models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.created_at).all() + return [ + { + "id": m.id, + "provider": m.provider, + "provider_name": m.provider_name, + "model_id": m.model_id, + "model_name": m.model_name, + "task_type": m.task_type, + "is_default": m.is_default, + "web_search_enabled": m.web_search_enabled, + "web_search_count": m.web_search_count or 5, + "description": m.description, + } + for m in models + ] diff --git a/backend/routers/architecture.py b/backend/routers/architecture.py new file mode 100644 index 0000000..e58abfc --- /dev/null +++ b/backend/routers/architecture.py @@ -0,0 +1,296 @@ +"""架构选型助手路由""" +import json +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from database import get_db +from models.user import User +from models.conversation import Conversation, Message +from schemas.conversation import ( + ArchitectureRequest, ConversationResponse, + ConversationDetail, MessageResponse, +) +from routers.auth import get_current_user +from services.ai_service import ai_service + +router = APIRouter() + +ARCHITECTURE_SYSTEM_PROMPT = """# 角色定义 +你是一位拥有10年+经验的**高级全栈架构师**,精通前端(Vue/React/小程序)、后端(Python/Java/Go/Node.js)、数据库(MySQL/PostgreSQL/MongoDB/Redis)、云服务与DevOps。你做过大量从0到1的项目,对技术选型的利弊、不同规模系统的架构模式了如指掌。 + +你的工作:接收用户提供的**已确认的功能需求**(可能来自需求助手的输出),给出完整的、可直接落地开发的技术方案。 + +> ⚠️ 本助手专注于**技术选型与架构设计**。如果用户发来的是原始甲方需求,建议先到「需求理解助手」进行需求分析。 + +# 核心理念 +- **没有最好的技术,只有最合适的技术**:选型必须匹配项目规模、团队能力和预算 +- **方案要能落地写代码**:不出纯理论的架构图,给的方案要具体到程序员能直接开干 +- **过度设计是大忌**:小项目用微服务是灾难,要敢于推荐简单方案 + +# 分析框架 + +## 第一步:项目画像评估 +- 项目规模:小型(个人/小团队)/ 中型(创业公司)/ 大型(企业级) +- 预期用户量和并发量 +- 团队技术栈偏好(如果用户有提及) +- 预算和时间约束 + +## 第二步:技术选型(带对比和理由) +针对每一层给出推荐方案和备选方案: +- 前端框架 + UI组件库 +- 后端语言 + Web框架 +- 数据库(主库 + 缓存) +- 文件存储方案 +- 部署方案 +- 第三方服务(如果需要) + +## 第三步:系统架构设计 +- 整体架构图(Mermaid语法) +- 核心数据模型(ER关系、表结构) +- 关键接口设计(RESTful API清单) +- 目录结构规划 + +## 第四步:技术难点与避坑指南 +- 基于实战经验,针对该项目的具体技术难点给出解决方案 +- 常见踩坑点和规避策略 +- 安全注意事项(XSS、CSRF、SQL注入、越权等) + +## 第五步:开发路线图 +- MVP版本应包含哪些功能 +- 迭代计划建议 +- 工期评估(按模块拆分前后端工时) + +# 输出规范 +严格使用以下 Markdown 结构输出: + +--- + +## 🎯 项目画像 +| 维度 | 评估 | +|------|------| +| 项目规模 | xxx | +| 预期用户量 | xxx | +| 推荐架构模式 | 单体/前后端分离/微服务 | + +## 🏗️ 技术选型 +| 层级 | 推荐方案 | 备选方案 | 选型理由 | +|------|---------|---------|---------| +| 前端框架 | xxx | xxx | xxx | +| UI组件库 | xxx | xxx | xxx | +| 后端框架 | xxx | xxx | xxx | +| 数据库 | xxx | xxx | xxx | +| 缓存 | xxx | xxx | xxx | +| 部署 | xxx | xxx | xxx | + +## 📐 系统架构图 +```mermaid +graph TB + A[前端] --> B[API网关] + B --> C[后端服务] + C --> D[数据库] +``` + +## 🗄️ 核心数据模型 +```sql +-- 表名: xxx +-- 说明: xxx +CREATE TABLE xxx ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + xxx VARCHAR(255) NOT NULL COMMENT 'xxx', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 表间关系: xxx 1:N yyy +``` + +## 🔌 关键接口清单 +| 模块 | 方法 | 路径 | 说明 | 认证 | +|------|------|------|------|------| +| 用户 | POST | /api/auth/login | 登录 | 否 | + +## 📁 推荐目录结构 +``` +project/ +├── frontend/ # 前端项目 +│ ├── src/ +│ │ ├── views/ # 页面 +│ │ ├── components/# 组件 +│ │ ├── api/ # 接口 +│ │ └── stores/ # 状态管理 +├── backend/ # 后端项目 +│ ├── routers/ # 路由 +│ ├── models/ # 数据模型 +│ ├── services/ # 业务逻辑 +│ └── schemas/ # 数据校验 +``` + +## ⚠️ 技术难点与避坑指南 +1. **【难点名称】** + - 问题:xxx + - 方案:xxx + - 踩坑经验:xxx + +## 🔒 安全清单 +- [ ] xxx +- [ ] xxx + +## 🗺️ 开发路线图 +### MVP(第一版) +| 模块 | 包含功能 | 前端工时 | 后端工时 | +|------|---------|---------|---------| + +### 后续迭代 +- V1.1: xxx +- V1.2: xxx + +--- + +# 交互原则 +1. **选型必须带理由**:不说"推荐用Vue",要说"推荐Vue 3,因为xxx;如果团队熟悉React也可以用" +2. **方案要分档**:针对不同预算/规模给出不同方案(如"预算充足用云服务,省钱可以用VPS") +3. **代码要能跑**:给出的SQL、目录结构、接口设计都要是可以直接使用的 +4. **架构图用Mermaid**:使用 ```mermaid 代码块,只用基础语法,不加样式 +5. **敢于说"不需要"**:如果项目不需要Redis/微服务/消息队列,要直说,不为了显得高级而过度设计 +6. **持续深化**:用户追问某个模块时,在已有方案基础上深入展开,保持一致性""" + + +@router.get("/conversations", response_model=List[ConversationResponse]) +def get_conversations( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取架构对话列表""" + conversations = ( + db.query(Conversation) + .filter(Conversation.user_id == current_user.id, Conversation.type == "architecture") + .order_by(Conversation.updated_at.desc()) + .all() + ) + return [ConversationResponse.model_validate(c) for c in conversations] + + +@router.get("/conversations/{conversation_id}", response_model=ConversationDetail) +def get_conversation_detail( + conversation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取对话详情""" + conv = db.query(Conversation).filter( + Conversation.id == conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + + messages = ( + db.query(Message) + .filter(Message.conversation_id == conversation_id) + .order_by(Message.created_at.asc()) + .all() + ) + result = ConversationDetail.model_validate(conv) + result.messages = [MessageResponse.model_validate(m) for m in messages] + return result + + +@router.post("/recommend") +async def recommend_architecture( + request: ArchitectureRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """架构推荐 - 流式输出""" + # 创建或获取对话 + if request.conversation_id: + conv = db.query(Conversation).filter( + Conversation.id == request.conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + else: + conv = Conversation( + user_id=current_user.id, + title=request.content[:50] if request.content else "新架构咨询", + type="architecture", + ) + db.add(conv) + db.commit() + db.refresh(conv) + + # 保存用户消息 + user_msg = Message( + conversation_id=conv.id, + role="user", + content=request.content, + ) + db.add(user_msg) + db.commit() + + # 构建历史消息 + history_msgs = ( + db.query(Message) + .filter(Message.conversation_id == conv.id) + .order_by(Message.created_at.asc()) + .all() + ) + messages = [{"role": msg.role, "content": msg.content} for msg in history_msgs] + + # 流式调用AI + async def generate(): + full_response = "" + try: + result = await ai_service.chat( + task_type="reasoning", + messages=messages, + system_prompt=ARCHITECTURE_SYSTEM_PROMPT, + stream=True, + model_config_id=request.model_config_id, + ) + if isinstance(result, str): + full_response = result + yield f"data: {json.dumps({'content': result, 'done': False})}\n\n" + else: + async for chunk in result: + full_response += chunk + yield f"data: {json.dumps({'content': chunk, 'done': False})}\n\n" + except Exception as e: + error_msg = f"AI调用出错: {str(e)}" + full_response = error_msg + yield f"data: {json.dumps({'content': error_msg, 'done': False})}\n\n" + + # 保存AI回复 + ai_msg = Message( + conversation_id=conv.id, + role="assistant", + content=full_response, + ) + db.add(ai_msg) + db.commit() + + yield f"data: {json.dumps({'content': '', 'done': True, 'conversation_id': conv.id})}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + +@router.delete("/conversations/{conversation_id}") +def delete_conversation( + conversation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """删除对话""" + conv = db.query(Conversation).filter( + Conversation.id == conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + + db.query(Message).filter(Message.conversation_id == conversation_id).delete() + db.delete(conv) + db.commit() + return {"message": "删除成功"} diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..cdb3bf7 --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,136 @@ +"""认证路由""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from passlib.context import CryptContext +from jose import JWTError, jwt +from datetime import datetime, timedelta +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from database import get_db +from models.user import User +from schemas.user import UserRegister, UserLogin, UserResponse, TokenResponse, UserUpdate +from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES + +router = APIRouter() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + + +def create_access_token(data: dict) -> str: + """创建JWT Token""" + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + """从Token获取当前用户""" + token = credentials.credentials + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException(status_code=401, detail="无效的认证凭据") + user_id = int(user_id) + except JWTError: + raise HTTPException(status_code=401, detail="无效的认证凭据") + + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException(status_code=401, detail="用户不存在") + return user + + +def get_admin_user(current_user: User = Depends(get_current_user)) -> User: + """要求当前用户是管理员""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="需要管理员权限") + return current_user + + +@router.post("/register") +def register(data: UserRegister, db: Session = Depends(get_db)): + """用户注册(需管理员审核后才可使用)""" + # 检查用户名是否已存在 + if db.query(User).filter(User.username == data.username).first(): + raise HTTPException(status_code=400, detail="用户名已存在") + if db.query(User).filter(User.email == data.email).first(): + raise HTTPException(status_code=400, detail="邮箱已被注册") + + # 创建用户(is_approved 默认 False,等待审核) + user = User( + username=data.username, + email=data.email, + password_hash=pwd_context.hash(data.password), + ) + db.add(user) + db.commit() + db.refresh(user) + + return {"message": "注册成功,请等待管理员审核通过后即可登录使用"} + + +@router.post("/login", response_model=TokenResponse) +def login(data: UserLogin, db: Session = Depends(get_db)): + """用户登录""" + user = db.query(User).filter(User.username == data.username).first() + if not user or not pwd_context.verify(data.password, user.password_hash): + raise HTTPException(status_code=401, detail="用户名或密码错误") + + if getattr(user, 'is_banned', False): + raise HTTPException(status_code=403, detail="账号已被封禁,请联系管理员") + + if not getattr(user, 'is_approved', False): + raise HTTPException(status_code=403, detail="账号尚未通过审核,请耐心等待管理员审核") + + token = create_access_token({"sub": str(user.id)}) + return TokenResponse( + access_token=token, + user=UserResponse.model_validate(user), + ) + + +@router.get("/me", response_model=UserResponse) +def get_me(current_user: User = Depends(get_current_user)): + """获取当前用户信息""" + return UserResponse.model_validate(current_user) + + +@router.put("/profile", response_model=UserResponse) +def update_profile( + data: UserUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """更新个人资料""" + # 修改用户名 + if data.username and data.username != current_user.username: + if db.query(User).filter(User.username == data.username, User.id != current_user.id).first(): + raise HTTPException(status_code=400, detail="用户名已存在") + current_user.username = data.username + + # 修改邮箱 + if data.email and data.email != current_user.email: + if db.query(User).filter(User.email == data.email, User.id != current_user.id).first(): + raise HTTPException(status_code=400, detail="邮箱已被使用") + current_user.email = data.email + + # 修改头像 + if data.avatar is not None: + current_user.avatar = data.avatar + + # 修改密码 + if data.new_password: + if not data.old_password: + raise HTTPException(status_code=400, detail="请输入当前密码") + if not pwd_context.verify(data.old_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="当前密码错误") + current_user.password_hash = pwd_context.hash(data.new_password) + + db.commit() + db.refresh(current_user) + return UserResponse.model_validate(current_user) diff --git a/backend/routers/bookmarks.py b/backend/routers/bookmarks.py new file mode 100644 index 0000000..fc1e18b --- /dev/null +++ b/backend/routers/bookmarks.py @@ -0,0 +1,118 @@ +"""网站收藏路由""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from database import get_db +from models.bookmark import BookmarkSite +from models.user import User +from schemas.bookmark import BookmarkCreate, BookmarkUpdate, BookmarkResponse, ReorderRequest +from routers.auth import get_current_user + +router = APIRouter() + + +@router.get("", response_model=List[BookmarkResponse]) +def get_bookmarks( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取当前用户的收藏网站列表""" + bookmarks = ( + db.query(BookmarkSite) + .filter(BookmarkSite.user_id == current_user.id) + .order_by(BookmarkSite.sort_order, BookmarkSite.created_at) + .all() + ) + return [BookmarkResponse.model_validate(b) for b in bookmarks] + + +@router.post("", response_model=BookmarkResponse) +def create_bookmark( + data: BookmarkCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """添加收藏网站""" + # 获取当前最大排序值 + max_order = ( + db.query(BookmarkSite.sort_order) + .filter(BookmarkSite.user_id == current_user.id) + .order_by(BookmarkSite.sort_order.desc()) + .first() + ) + next_order = (max_order[0] + 1) if max_order else 0 + + bookmark = BookmarkSite( + user_id=current_user.id, + name=data.name, + url=data.url, + icon=data.icon, + sort_order=next_order, + ) + db.add(bookmark) + db.commit() + db.refresh(bookmark) + return BookmarkResponse.model_validate(bookmark) + + +@router.put("/reorder") +def reorder_bookmarks( + data: ReorderRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """批量更新排序""" + for item in data.items: + db.query(BookmarkSite).filter( + BookmarkSite.id == item.id, + BookmarkSite.user_id == current_user.id, + ).update({"sort_order": item.sort_order}) + db.commit() + return {"message": "排序已更新"} + + +@router.put("/{bookmark_id}", response_model=BookmarkResponse) +def update_bookmark( + bookmark_id: int, + data: BookmarkUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """编辑收藏网站""" + bookmark = db.query(BookmarkSite).filter( + BookmarkSite.id == bookmark_id, + BookmarkSite.user_id == current_user.id, + ).first() + if not bookmark: + raise HTTPException(status_code=404, detail="收藏不存在") + + if data.name is not None: + bookmark.name = data.name + if data.url is not None: + bookmark.url = data.url + if data.icon is not None: + bookmark.icon = data.icon + if data.sort_order is not None: + bookmark.sort_order = data.sort_order + + db.commit() + db.refresh(bookmark) + return BookmarkResponse.model_validate(bookmark) + + +@router.delete("/{bookmark_id}") +def delete_bookmark( + bookmark_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """删除收藏网站""" + bookmark = db.query(BookmarkSite).filter( + BookmarkSite.id == bookmark_id, + BookmarkSite.user_id == current_user.id, + ).first() + if not bookmark: + raise HTTPException(status_code=404, detail="收藏不存在") + db.delete(bookmark) + db.commit() + return {"message": "删除成功"} diff --git a/backend/routers/knowledge_base.py b/backend/routers/knowledge_base.py new file mode 100644 index 0000000..adbaf02 --- /dev/null +++ b/backend/routers/knowledge_base.py @@ -0,0 +1,633 @@ +"""团队知识库路由""" +import json +import hashlib +from typing import Optional, List +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, Header, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func, or_ +from pydantic import BaseModel + +from database import get_db +from config import SECRET_KEY +from models.user import User +from models.post import Post +from models.system_config import SystemConfig +from models.knowledge_base import KbCategory, KbItem, KbAccessLog +from models.attachment import Attachment +from routers.auth import get_current_user, get_admin_user +from services.ai_service import ai_service + +router = APIRouter() + + +# ========== 密码机制(复用 API Hub 方案) ========== + +def _get_kb_password(db: Session) -> str: + cfg = db.query(SystemConfig).filter(SystemConfig.key == "kb_password").first() + return cfg.value if cfg else "" + + +def _password_version(db: Session) -> str: + """返回密码哈希前8位作为版本标识,密码变更后旧token自动失效""" + pwd = _get_kb_password(db) + return pwd[:8] if pwd else "none" + + +def _hash_password(pwd: str) -> str: + return hashlib.sha256(pwd.encode()).hexdigest() + + +def _create_kb_token(user_id: int, pwd_ver: str = "none") -> str: + from jose import jwt + exp = datetime.utcnow() + timedelta(hours=2) + return jwt.encode({"sub": str(user_id), "kb": True, "pv": pwd_ver, "exp": exp}, SECRET_KEY, algorithm="HS256") + + +def verify_kb_access( + x_kb_token: Optional[str] = Header(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """验证用户登录 + 知识库访问令牌""" + if not x_kb_token: + raise HTTPException(status_code=403, detail="需要知识库访问权限,请先验证密码") + from jose import jwt, JWTError + try: + payload = jwt.decode(x_kb_token, SECRET_KEY, algorithms=["HS256"]) + if not payload.get("kb"): + raise HTTPException(status_code=403, detail="无效的知识库令牌") + # 检查密码版本是否匹配 + token_pv = payload.get("pv", "") + current_pv = _password_version(db) + if token_pv != current_pv: + raise HTTPException(status_code=403, detail="密码已变更,请重新验证") + except JWTError: + raise HTTPException(status_code=403, detail="知识库令牌已过期,请重新验证密码") + return current_user + + +# ========== Schemas ========== + +class KbCategoryCreate(BaseModel): + name: str + icon: str = "" + +class KbCategoryUpdate(BaseModel): + name: Optional[str] = None + icon: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + +class KbItemAdd(BaseModel): + post_ids: List[int] + category_id: Optional[int] = None + +class KbItemUpdate(BaseModel): + category_id: Optional[int] = None + title: Optional[str] = None + summary: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + +class KbAiChatRequest(BaseModel): + question: str + +class PasswordBody(BaseModel): + password: str + + +# ========== 密码认证接口 ========== + +@router.post("/auth") +def kb_auth( + body: PasswordBody, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """知识库密码验证""" + stored = _get_kb_password(db) + pv = _password_version(db) + if not stored: + # 未设置密码,直接放行 + token = _create_kb_token(current_user.id, pv) + return {"token": token} + if _hash_password(body.password) != stored: + raise HTTPException(status_code=401, detail="密码错误") + token = _create_kb_token(current_user.id, pv) + return {"token": token} + + +@router.get("/check-password") +def kb_check_password( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """检查知识库是否需要密码""" + stored = _get_kb_password(db) + return {"has_password": bool(stored)} + + +# ========== 公开接口(需登录 + kb_token) ========== + +@router.get("/categories") +def get_categories( + user: User = Depends(verify_kb_access), + db: Session = Depends(get_db), +): + """获取知识库分类列表""" + cats = db.query(KbCategory).filter(KbCategory.is_active == True).order_by(KbCategory.sort_order, KbCategory.id).all() + result = [] + for c in cats: + count = db.query(sa_func.count(KbItem.id)).filter(KbItem.category_id == c.id, KbItem.is_active == True).scalar() or 0 + result.append({"id": c.id, "name": c.name, "icon": c.icon, "count": count}) + return result + + +@router.get("/items") +def get_items( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + category_id: Optional[int] = None, + keyword: Optional[str] = None, + user: User = Depends(verify_kb_access), + db: Session = Depends(get_db), +): + """获取知识库条目列表""" + query = db.query(KbItem).filter(KbItem.is_active == True) + if category_id: + query = query.filter(KbItem.category_id == category_id) + if keyword: + kw = f"%{keyword}%" + query = query.filter(or_(KbItem.title.like(kw), KbItem.summary.like(kw))) + total = query.count() + items = query.order_by(KbItem.sort_order, KbItem.created_at.desc()).offset((page - 1) * size).limit(size).all() + + result = [] + for item in items: + post = db.query(Post).filter(Post.id == item.post_id).first() + cat = db.query(KbCategory).filter(KbCategory.id == item.category_id).first() if item.category_id else None + author = db.query(User).filter(User.id == item.added_by).first() if item.added_by else None + result.append({ + "id": item.id, + "title": item.title, + "summary": item.summary or (post.content[:150] if post else ""), + "category_id": item.category_id, + "category_name": cat.name if cat else "", + "post_id": item.post_id, + "post_author": post.user_id if post else None, + "added_by_name": author.username if author else "", + "created_at": item.created_at.isoformat() if item.created_at else None, + }) + + # 记录访问日志 + if keyword: + db.add(KbAccessLog(user_id=user.id, action="search", query=keyword)) + db.commit() + + return {"items": result, "total": total, "page": page, "size": size} + + +@router.get("/items/{item_id}") +def get_item_detail( + item_id: int, + user: User = Depends(verify_kb_access), + db: Session = Depends(get_db), +): + """获取知识库条目详情(含帖子完整内容)""" + item = db.query(KbItem).filter(KbItem.id == item_id, KbItem.is_active == True).first() + if not item: + raise HTTPException(status_code=404, detail="条目不存在") + + post = db.query(Post).filter(Post.id == item.post_id).first() + cat = db.query(KbCategory).filter(KbCategory.id == item.category_id).first() if item.category_id else None + post_author = db.query(User).filter(User.id == post.user_id).first() if post else None + + # 记录访问 + db.add(KbAccessLog(user_id=user.id, action="view", query=str(item_id))) + db.commit() + + return { + "id": item.id, + "title": item.title, + "summary": item.summary, + "category_id": item.category_id, + "category_name": cat.name if cat else "", + "post_id": item.post_id, + "post_title": post.title if post else "", + "post_content": post.content if post else "", + "post_author": {"id": post_author.id, "username": post_author.username} if post_author else None, + "post_tags": post.tags if post else "", + "post_category": post.category if post else "", + "created_at": item.created_at.isoformat() if item.created_at else None, + "attachments": [ + {"id": a.id, "filename": a.filename, "url": a.url, "file_size": a.file_size, "file_type": a.file_type} + for a in db.query(Attachment).filter(Attachment.post_id == item.post_id).order_by(Attachment.created_at.asc()).all() + ] if post else [], + } + + +@router.get("/stats") +def get_kb_stats( + user: User = Depends(verify_kb_access), + db: Session = Depends(get_db), +): + """获取知识库统计""" + total_items = db.query(sa_func.count(KbItem.id)).filter(KbItem.is_active == True).scalar() or 0 + total_categories = db.query(sa_func.count(KbCategory.id)).filter(KbCategory.is_active == True).scalar() or 0 + total_views = db.query(sa_func.count(KbAccessLog.id)).filter(KbAccessLog.action == "view").scalar() or 0 + total_searches = db.query(sa_func.count(KbAccessLog.id)).filter(KbAccessLog.action == "search").scalar() or 0 + total_ai_chats = db.query(sa_func.count(KbAccessLog.id)).filter(KbAccessLog.action == "ai_chat").scalar() or 0 + return { + "total_items": total_items, + "total_categories": total_categories, + "total_views": total_views, + "total_searches": total_searches, + "total_ai_chats": total_ai_chats, + } + + +# ========== AI 智能问答 ========== + +KB_AI_SYSTEM_PROMPT = """你是一个团队知识库的AI助手。你的任务是根据知识库中的内容回答用户的问题。 + +规则: +1. 只基于提供的知识库内容回答问题,不编造信息 +2. 如果知识库中没有相关内容,诚实告知用户 +3. 回答时引用来源文章的标题 +4. 使用 Markdown 格式组织回答 +5. 回答要简洁、专业、有条理""" + + +@router.post("/ai-chat") +async def kb_ai_chat( + request: KbAiChatRequest, + user: User = Depends(verify_kb_access), + db: Session = Depends(get_db), +): + """AI 智能问答(SSE 流式)""" + question = request.question.strip() + if not question: + raise HTTPException(status_code=400, detail="请输入问题") + + # 从知识库中检索相关内容作为上下文 + kw = f"%{question[:50]}%" + # 简单关键词匹配(后续可升级为向量搜索) + related_items = ( + db.query(KbItem) + .filter(KbItem.is_active == True) + .filter(or_(KbItem.title.like(kw), KbItem.summary.like(kw))) + .limit(5) + .all() + ) + + # 如果关键词搜索结果不足,补充最新的条目 + if len(related_items) < 3: + existing_ids = [item.id for item in related_items] + extra = ( + db.query(KbItem) + .filter(KbItem.is_active == True, ~KbItem.id.in_(existing_ids) if existing_ids else True) + .order_by(KbItem.created_at.desc()) + .limit(10 - len(related_items)) + .all() + ) + related_items.extend(extra) + + # 构建知识库上下文 + context_parts = [] + for item in related_items: + post = db.query(Post).filter(Post.id == item.post_id).first() + if post: + content = post.content[:2000] # 限制每篇长度 + context_parts.append(f"### {item.title}\n{content}") + + kb_context = "\n\n---\n\n".join(context_parts) if context_parts else "知识库暂无相关内容。" + + messages = [ + {"role": "user", "content": f"以下是知识库中的相关内容:\n\n{kb_context}\n\n---\n\n用户问题:{question}"} + ] + + # 记录日志 + db.add(KbAccessLog(user_id=user.id, action="ai_chat", query=question)) + db.commit() + + # 流式调用AI + async def generate(): + full_response = "" + try: + result = await ai_service.chat( + task_type="reasoning", + messages=messages, + system_prompt=KB_AI_SYSTEM_PROMPT, + stream=True, + ) + if isinstance(result, str): + full_response = result + yield f"data: {json.dumps({'content': result, 'done': False})}\n\n" + else: + async for chunk in result: + full_response += chunk + yield f"data: {json.dumps({'content': chunk, 'done': False})}\n\n" + except Exception as e: + error_msg = f"AI调用出错: {str(e)}" + yield f"data: {json.dumps({'content': error_msg, 'done': False})}\n\n" + + yield f"data: {json.dumps({'content': '', 'done': True})}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + +# ========== 管理员接口 ========== + +# --- 密码管理 --- + +@router.put("/admin/password") +def set_kb_password( + body: PasswordBody, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """设置/修改知识库密码""" + hashed = _hash_password(body.password) if body.password else "" + row = db.query(SystemConfig).filter(SystemConfig.key == "kb_password").first() + if row: + row.value = hashed + else: + db.add(SystemConfig(key="kb_password", value=hashed, description="知识库访问密码")) + db.commit() + return {"message": "密码已更新"} + + +@router.get("/admin/password-status") +def get_kb_password_status( + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """获取知识库密码状态""" + stored = _get_kb_password(db) + return {"has_password": bool(stored)} + + +# --- 分类管理 --- + +@router.get("/admin/categories") +def admin_list_categories( + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """获取所有知识库分类(含禁用)""" + cats = db.query(KbCategory).order_by(KbCategory.sort_order, KbCategory.id).all() + return [ + { + "id": c.id, "name": c.name, "icon": c.icon, + "sort_order": c.sort_order, "is_active": c.is_active, + "item_count": db.query(sa_func.count(KbItem.id)).filter(KbItem.category_id == c.id).scalar() or 0, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in cats + ] + + +@router.post("/admin/categories") +def admin_create_category( + data: KbCategoryCreate, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """创建知识库分类""" + exists = db.query(KbCategory).filter(KbCategory.name == data.name).first() + if exists: + raise HTTPException(status_code=400, detail="分类名称已存在") + cat = KbCategory(name=data.name, icon=data.icon) + db.add(cat) + db.commit() + db.refresh(cat) + return {"id": cat.id, "name": cat.name, "message": "创建成功"} + + +@router.put("/admin/categories/{cat_id}") +def admin_update_category( + cat_id: int, + data: KbCategoryUpdate, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """更新知识库分类""" + cat = db.query(KbCategory).filter(KbCategory.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + updates = data.dict(exclude_none=True) + for key, value in updates.items(): + setattr(cat, key, value) + db.commit() + return {"message": "更新成功"} + + +@router.delete("/admin/categories/{cat_id}") +def admin_delete_category( + cat_id: int, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """删除知识库分类""" + cat = db.query(KbCategory).filter(KbCategory.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + # 将该分类下的条目设为未分类 + db.query(KbItem).filter(KbItem.category_id == cat_id).update({"category_id": None}) + db.delete(cat) + db.commit() + return {"message": "删除成功"} + + +# --- 条目管理 --- + +@router.get("/admin/items") +def admin_list_items( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + category_id: Optional[int] = None, + keyword: Optional[str] = None, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """获取所有知识库条目""" + query = db.query(KbItem) + if category_id: + query = query.filter(KbItem.category_id == category_id) + if keyword: + kw = f"%{keyword}%" + query = query.filter(or_(KbItem.title.like(kw), KbItem.summary.like(kw))) + total = query.count() + items = query.order_by(KbItem.created_at.desc()).offset((page - 1) * size).limit(size).all() + + result = [] + for item in items: + post = db.query(Post).filter(Post.id == item.post_id).first() + cat = db.query(KbCategory).filter(KbCategory.id == item.category_id).first() if item.category_id else None + author = db.query(User).filter(User.id == item.added_by).first() if item.added_by else None + post_author = db.query(User).filter(User.id == post.user_id).first() if post else None + result.append({ + "id": item.id, + "title": item.title, + "summary": item.summary or "", + "category_id": item.category_id, + "category_name": cat.name if cat else "未分类", + "post_id": item.post_id, + "post_title": post.title if post else "(已删除)", + "post_author_name": post_author.username if post_author else "", + "added_by_name": author.username if author else "", + "is_active": item.is_active, + "sort_order": item.sort_order, + "created_at": item.created_at.isoformat() if item.created_at else None, + }) + return {"items": result, "total": total, "page": page, "size": size} + + +@router.post("/admin/items") +def admin_add_items( + data: KbItemAdd, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """从帖子批量添加到知识库""" + added = 0 + skipped = 0 + for post_id in data.post_ids: + # 检查是否已存在 + exists = db.query(KbItem).filter(KbItem.post_id == post_id).first() + if exists: + skipped += 1 + continue + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + skipped += 1 + continue + item = KbItem( + post_id=post_id, + category_id=data.category_id, + title=post.title, + summary=post.content[:200] if post.content else "", + added_by=admin.id, + ) + db.add(item) + added += 1 + db.commit() + return {"message": f"已添加 {added} 条,跳过 {skipped} 条(已存在或不存在)", "added": added, "skipped": skipped} + + +@router.put("/admin/items/{item_id}") +def admin_update_item( + item_id: int, + data: KbItemUpdate, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """更新知识库条目""" + item = db.query(KbItem).filter(KbItem.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="条目不存在") + updates = data.dict(exclude_none=True) + for key, value in updates.items(): + setattr(item, key, value) + db.commit() + return {"message": "更新成功"} + + +@router.delete("/admin/items/{item_id}") +def admin_delete_item( + item_id: int, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """删除知识库条目""" + item = db.query(KbItem).filter(KbItem.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="条目不存在") + db.delete(item) + db.commit() + return {"message": "删除成功"} + + +@router.get("/admin/posts-for-pick") +def admin_posts_for_pick( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=50), + keyword: Optional[str] = None, + category: Optional[str] = None, + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """获取可选帖子列表(排除已加入知识库的)""" + existing_post_ids = [r[0] for r in db.query(KbItem.post_id).all()] + query = db.query(Post).filter(Post.is_public == True) + if existing_post_ids: + query = query.filter(~Post.id.in_(existing_post_ids)) + if keyword: + kw = f"%{keyword}%" + query = query.filter(or_(Post.title.like(kw), Post.content.like(kw))) + if category: + query = query.filter(Post.category == category) + total = query.count() + posts = query.order_by(Post.created_at.desc()).offset((page - 1) * size).limit(size).all() + + result = [] + for p in posts: + author = db.query(User).filter(User.id == p.user_id).first() + result.append({ + "id": p.id, + "title": p.title, + "category": p.category, + "content_preview": p.content[:100] if p.content else "", + "author_name": author.username if author else "", + "like_count": p.like_count, + "view_count": p.view_count, + "created_at": p.created_at.isoformat() if p.created_at else None, + }) + return {"items": result, "total": total, "page": page, "size": size} + + +# --- 管理员统计 --- + +@router.get("/admin/stats") +def admin_kb_stats( + admin: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + """管理员统计数据""" + total_items = db.query(sa_func.count(KbItem.id)).scalar() or 0 + active_items = db.query(sa_func.count(KbItem.id)).filter(KbItem.is_active == True).scalar() or 0 + total_categories = db.query(sa_func.count(KbCategory.id)).scalar() or 0 + total_views = db.query(sa_func.count(KbAccessLog.id)).filter(KbAccessLog.action == "view").scalar() or 0 + total_searches = db.query(sa_func.count(KbAccessLog.id)).filter(KbAccessLog.action == "search").scalar() or 0 + total_ai_chats = db.query(sa_func.count(KbAccessLog.id)).filter(KbAccessLog.action == "ai_chat").scalar() or 0 + + # 最近7天趋势 + from datetime import date + today = date.today() + daily_stats = [] + for i in range(6, -1, -1): + d = today - timedelta(days=i) + day_start = datetime.combine(d, datetime.min.time()) + day_end = datetime.combine(d, datetime.max.time()) + views = db.query(sa_func.count(KbAccessLog.id)).filter( + KbAccessLog.action == "view", KbAccessLog.created_at.between(day_start, day_end) + ).scalar() or 0 + searches = db.query(sa_func.count(KbAccessLog.id)).filter( + KbAccessLog.action == "search", KbAccessLog.created_at.between(day_start, day_end) + ).scalar() or 0 + ai_chats = db.query(sa_func.count(KbAccessLog.id)).filter( + KbAccessLog.action == "ai_chat", KbAccessLog.created_at.between(day_start, day_end) + ).scalar() or 0 + daily_stats.append({"date": d.isoformat(), "views": views, "searches": searches, "ai_chats": ai_chats}) + + return { + "total_items": total_items, + "active_items": active_items, + "total_categories": total_categories, + "total_views": total_views, + "total_searches": total_searches, + "total_ai_chats": total_ai_chats, + "daily_stats": daily_stats, + } diff --git a/backend/routers/nav.py b/backend/routers/nav.py new file mode 100644 index 0000000..ac81c82 --- /dev/null +++ b/backend/routers/nav.py @@ -0,0 +1,360 @@ +"""导航站路由""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func +from pydantic import BaseModel +from typing import Optional + +from database import get_db +from models.user import User +from models.nav_category import NavCategory +from models.nav_link import NavLink +from routers.auth import get_current_user, get_admin_user + +router = APIRouter() + + +# ========== Schemas ========== + +class NavCategoryCreate(BaseModel): + name: str + icon: str = "" + +class NavCategoryUpdate(BaseModel): + name: Optional[str] = None + icon: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + +class NavLinkCreate(BaseModel): + category_id: int + name: str + url: str + icon: str = "" + description: str = "" + +class NavLinkUpdate(BaseModel): + category_id: Optional[int] = None + name: Optional[str] = None + url: Optional[str] = None + icon: Optional[str] = None + description: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + +class NavLinkSubmit(BaseModel): + """用户提交导航链接""" + category_id: int + name: str + url: str + icon: str = "" + description: str = "" + +class NavLinkReview(BaseModel): + """审核操作""" + action: str # approve / reject + reject_reason: str = "" + + +# ========== 管理员接口 ========== + +@router.get("/admin/categories") +def admin_list_categories( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """获取所有导航分类(含禁用)""" + cats = db.query(NavCategory).order_by(NavCategory.sort_order, NavCategory.id).all() + return [ + { + "id": c.id, "name": c.name, "icon": c.icon, + "sort_order": c.sort_order, "is_active": c.is_active, + "link_count": db.query(sa_func.count(NavLink.id)).filter(NavLink.category_id == c.id).scalar() or 0, + } + for c in cats + ] + + +@router.post("/admin/categories") +def admin_create_category( + data: NavCategoryCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """新增导航分类""" + existing = db.query(NavCategory).filter(NavCategory.name == data.name).first() + if existing: + raise HTTPException(status_code=400, detail="分类名称已存在") + max_order = db.query(sa_func.max(NavCategory.sort_order)).scalar() or 0 + cat = NavCategory(name=data.name, icon=data.icon, sort_order=max_order + 1) + db.add(cat) + db.commit() + db.refresh(cat) + return {"id": cat.id, "name": cat.name, "icon": cat.icon, "sort_order": cat.sort_order, "is_active": cat.is_active} + + +@router.put("/admin/categories/{cat_id}") +def admin_update_category( + cat_id: int, + data: NavCategoryUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """编辑导航分类""" + cat = db.query(NavCategory).filter(NavCategory.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + if data.name is not None: + dup = db.query(NavCategory).filter(NavCategory.name == data.name, NavCategory.id != cat_id).first() + if dup: + raise HTTPException(status_code=400, detail="分类名称已存在") + cat.name = data.name + if data.icon is not None: + cat.icon = data.icon + if data.sort_order is not None: + cat.sort_order = data.sort_order + if data.is_active is not None: + cat.is_active = data.is_active + db.commit() + db.refresh(cat) + return {"id": cat.id, "name": cat.name, "icon": cat.icon, "sort_order": cat.sort_order, "is_active": cat.is_active} + + +@router.delete("/admin/categories/{cat_id}") +def admin_delete_category( + cat_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """删除导航分类(级联删除链接)""" + cat = db.query(NavCategory).filter(NavCategory.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + db.query(NavLink).filter(NavLink.category_id == cat_id).delete() + db.delete(cat) + db.commit() + return {"message": "删除成功"} + + +@router.get("/admin/links") +def admin_list_links( + category_id: Optional[int] = None, + status: Optional[str] = None, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """获取导航链接列表""" + query = db.query(NavLink) + if category_id is not None: + query = query.filter(NavLink.category_id == category_id) + if status is not None: + query = query.filter(NavLink.status == status) + links = query.order_by(NavLink.sort_order, NavLink.id).all() + return [ + { + "id": l.id, "category_id": l.category_id, "name": l.name, + "url": l.url, "icon": l.icon, "description": l.description, + "sort_order": l.sort_order, "is_active": l.is_active, + "status": l.status, "submitted_by": l.submitted_by, + "reject_reason": l.reject_reason or "", + } + for l in links + ] + + +@router.post("/admin/links") +def admin_create_link( + data: NavLinkCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """新增导航链接""" + cat = db.query(NavCategory).filter(NavCategory.id == data.category_id).first() + if not cat: + raise HTTPException(status_code=400, detail="分类不存在") + max_order = db.query(sa_func.max(NavLink.sort_order)).filter(NavLink.category_id == data.category_id).scalar() or 0 + link = NavLink( + category_id=data.category_id, name=data.name, url=data.url, + icon=data.icon, description=data.description, sort_order=max_order + 1, + ) + db.add(link) + db.commit() + db.refresh(link) + return { + "id": link.id, "category_id": link.category_id, "name": link.name, + "url": link.url, "icon": link.icon, "description": link.description, + "sort_order": link.sort_order, "is_active": link.is_active, + } + + +@router.put("/admin/links/{link_id}") +def admin_update_link( + link_id: int, + data: NavLinkUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """编辑导航链接""" + link = db.query(NavLink).filter(NavLink.id == link_id).first() + if not link: + raise HTTPException(status_code=404, detail="链接不存在") + if data.category_id is not None: + link.category_id = data.category_id + if data.name is not None: + link.name = data.name + if data.url is not None: + link.url = data.url + if data.icon is not None: + link.icon = data.icon + if data.description is not None: + link.description = data.description + if data.sort_order is not None: + link.sort_order = data.sort_order + if data.is_active is not None: + link.is_active = data.is_active + db.commit() + db.refresh(link) + return { + "id": link.id, "category_id": link.category_id, "name": link.name, + "url": link.url, "icon": link.icon, "description": link.description, + "sort_order": link.sort_order, "is_active": link.is_active, + } + + +@router.delete("/admin/links/{link_id}") +def admin_delete_link( + link_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """删除导航链接""" + link = db.query(NavLink).filter(NavLink.id == link_id).first() + if not link: + raise HTTPException(status_code=404, detail="链接不存在") + db.delete(link) + db.commit() + return {"message": "删除成功"} + + +@router.get("/admin/pending-count") +def admin_pending_count( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """获取待审核数量""" + count = db.query(sa_func.count(NavLink.id)).filter(NavLink.status == "pending").scalar() or 0 + return {"count": count} + + +@router.put("/admin/links/{link_id}/review") +def admin_review_link( + link_id: int, + data: NavLinkReview, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """审核导航链接""" + link = db.query(NavLink).filter(NavLink.id == link_id).first() + if not link: + raise HTTPException(status_code=404, detail="链接不存在") + if data.action == "approve": + link.status = "approved" + link.is_active = True + link.reject_reason = "" + elif data.action == "reject": + link.status = "rejected" + link.is_active = False + link.reject_reason = data.reject_reason + else: + raise HTTPException(status_code=400, detail="无效操作,请使用 approve 或 reject") + db.commit() + db.refresh(link) + return { + "id": link.id, "status": link.status, + "is_active": link.is_active, "reject_reason": link.reject_reason or "", + } + + +# ========== 用户提交接口 ========== + +@router.post("/submit") +def user_submit_link( + data: NavLinkSubmit, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """用户提交导航网站(需管理员审核)""" + cat = db.query(NavCategory).filter(NavCategory.id == data.category_id).first() + if not cat: + raise HTTPException(status_code=400, detail="分类不存在") + existing = db.query(NavLink).filter(NavLink.url == data.url).first() + if existing: + raise HTTPException(status_code=400, detail="该网站已被提交过") + link = NavLink( + category_id=data.category_id, name=data.name, url=data.url, + icon=data.icon, description=data.description, + status="pending", submitted_by=user.id, is_active=False, + ) + db.add(link) + db.commit() + db.refresh(link) + return { + "id": link.id, "name": link.name, "url": link.url, + "status": link.status, "message": "提交成功,等待管理员审核", + } + + +@router.get("/my-submissions") +def user_my_submissions( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """用户查看自己提交的记录""" + links = db.query(NavLink).filter(NavLink.submitted_by == user.id).order_by(NavLink.id.desc()).all() + return [ + { + "id": l.id, "name": l.name, "url": l.url, "icon": l.icon, + "description": l.description, "status": l.status, + "reject_reason": l.reject_reason or "", + "created_at": l.created_at.isoformat() if l.created_at else "", + } + for l in links + ] + + +# ========== 公开接口 ========== + +@router.get("/public") +def get_public_nav( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取所有启用分类及其下启用的链接""" + cats = db.query(NavCategory).filter(NavCategory.is_active == True).order_by(NavCategory.sort_order, NavCategory.id).all() + result = [] + for c in cats: + links = ( + db.query(NavLink) + .filter(NavLink.category_id == c.id, NavLink.is_active == True, NavLink.status == "approved") + .order_by(NavLink.sort_order, NavLink.id) + .all() + ) + if links: + result.append({ + "id": c.id, "name": c.name, "icon": c.icon, + "links": [ + {"id": l.id, "name": l.name, "url": l.url, "icon": l.icon, "description": l.description} + for l in links + ], + }) + return result + + +@router.get("/public/categories") +def get_public_categories( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取所有启用分类(供用户提交时选择)""" + cats = db.query(NavCategory).filter(NavCategory.is_active == True).order_by(NavCategory.sort_order, NavCategory.id).all() + return [{"id": c.id, "name": c.name, "icon": c.icon} for c in cats] diff --git a/backend/routers/notifications.py b/backend/routers/notifications.py new file mode 100644 index 0000000..be721f8 --- /dev/null +++ b/backend/routers/notifications.py @@ -0,0 +1,89 @@ +"""消息通知路由""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import func +from database import get_db +from models.user import User +from models.notification import Notification +from routers.auth import get_current_user + +router = APIRouter() + + +@router.get("") +def get_notifications( + page: int = 1, + page_size: int = 30, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取通知列表""" + notifs = ( + db.query(Notification) + .filter(Notification.user_id == current_user.id) + .order_by(Notification.created_at.desc()) + .offset((page - 1) * page_size).limit(page_size).all() + ) + + # 获取触发用户信息 + from_ids = list(set(n.from_user_id for n in notifs if n.from_user_id)) + users = db.query(User).filter(User.id.in_(from_ids)).all() if from_ids else [] + user_map = {u.id: u for u in users} + + return [ + { + "id": n.id, + "type": n.type, + "content": n.content, + "related_id": n.related_id, + "is_read": n.is_read, + "created_at": n.created_at, + "from_user": { + "id": n.from_user_id, + "username": user_map[n.from_user_id].username, + "avatar": user_map[n.from_user_id].avatar, + } if n.from_user_id and n.from_user_id in user_map else None, + } + for n in notifs + ] + + +@router.get("/unread-count") +def get_unread_count( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取未读通知数量""" + count = ( + db.query(func.count(Notification.id)) + .filter(Notification.user_id == current_user.id, Notification.is_read == False) + .scalar() + ) + return {"count": count} + + +@router.put("/read-all") +def read_all( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """全部标为已读""" + db.query(Notification).filter( + Notification.user_id == current_user.id, Notification.is_read == False + ).update({"is_read": True}) + db.commit() + return {"message": "已全部标为已读"} + + +@router.put("/{notif_id}/read") +def read_one( + notif_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """单条标为已读""" + db.query(Notification).filter( + Notification.id == notif_id, Notification.user_id == current_user.id + ).update({"is_read": True}) + db.commit() + return {"message": "已标为已读"} diff --git a/backend/routers/posts.py b/backend/routers/posts.py new file mode 100644 index 0000000..ac41a0a --- /dev/null +++ b/backend/routers/posts.py @@ -0,0 +1,440 @@ +"""经验知识库路由""" +import json +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import or_ + +from database import get_db +from models.user import User +from models.post import Post +from models.comment import Comment +from models.like import Like, Collect +from models.follow import Follow +from models.notification import Notification +from models.attachment import Attachment +from schemas.post import ( + PostCreate, PostUpdate, PostResponse, PostListResponse, + CommentCreate, CommentResponse, +) +from routers.auth import get_current_user + +router = APIRouter() + + +import re + + +def _extract_cover_image(content: str) -> str: + """从Markdown内容中提取第一张图片作为封面""" + if not content: + return "" + match = re.search(r'!\[.*?\]\((.*?)\)', content) + if match: + return match.group(1) + img_match = re.search(r']+src=["\']([^"\']+)["\']', content) + if img_match: + return img_match.group(1) + return "" + + +def _enrich_post_with_author(post: Post, db: Session) -> dict: + """为帖子附加作者信息(用于信息流)""" + author = db.query(User).filter(User.id == post.user_id).first() + return { + "id": post.id, "title": post.title, "content": post.content[:200], + "category": post.category, "tags": post.tags, + "cover_image": _extract_cover_image(post.content), + "view_count": post.view_count, "like_count": post.like_count, + "comment_count": post.comment_count, "collect_count": post.collect_count, + "created_at": post.created_at, "updated_at": post.updated_at, + "author": { + "id": post.user_id, + "username": author.username if author else "未知", + "avatar": author.avatar if author else "", + }, + } + + +@router.get("/feed") +def get_feed( + page: int = 1, page_size: int = 20, + category: Optional[str] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """关注的人的帖子流""" + following_ids = [f.following_id for f in db.query(Follow.following_id).filter(Follow.follower_id == current_user.id).all()] + if not following_ids: + return {"items": [], "total": 0, "page": page, "page_size": page_size} + query = db.query(Post).filter(Post.user_id.in_(following_ids), Post.is_public == True, Post.is_draft == False) + if category: + query = query.filter(Post.category == category) + total = query.count() + posts = ( + query.order_by(Post.created_at.desc()) + .offset((page - 1) * page_size).limit(page_size).all() + ) + return {"items": [_enrich_post_with_author(p, db) for p in posts], "total": total, "page": page, "page_size": page_size} + + +@router.get("/hot") +def get_hot_posts( + page: int = 1, page_size: int = 20, + category: Optional[str] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """热门帖子(按热度排序)""" + query = db.query(Post).filter(Post.is_public == True, Post.is_draft == False) + if category: + query = query.filter(Post.category == category) + total = query.count() + posts = ( + query.order_by((Post.like_count * 3 + Post.comment_count * 2 + Post.view_count).desc()) + .offset((page - 1) * page_size).limit(page_size).all() + ) + return {"items": [_enrich_post_with_author(p, db) for p in posts], "total": total, "page": page, "page_size": page_size} + + +@router.get("/latest") +def get_latest_posts( + page: int = 1, page_size: int = 20, + category: Optional[str] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """最新帖子""" + query = db.query(Post).filter(Post.is_public == True, Post.is_draft == False) + if category: + query = query.filter(Post.category == category) + total = query.count() + posts = ( + query.order_by(Post.created_at.desc()) + .offset((page - 1) * page_size).limit(page_size).all() + ) + return {"items": [_enrich_post_with_author(p, db) for p in posts], "total": total, "page": page, "page_size": page_size} + + +@router.get("/drafts") +def get_drafts( + page: int = 1, page_size: int = 20, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取当前用户的草稿列表""" + query = db.query(Post).filter(Post.user_id == current_user.id, Post.is_draft == True) + total = query.count() + posts = query.order_by(Post.updated_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + return { + "items": [_enrich_post(p, db, current_user.id) for p in posts], + "total": total, "page": page, "page_size": page_size, + } + + +def _enrich_post(post: Post, db: Session, current_user_id: int = None) -> PostResponse: + """填充帖子额外字段""" + author = db.query(User).filter(User.id == post.user_id).first() + result = PostResponse.model_validate(post) + result.author_name = author.username if author else "未知用户" + if current_user_id: + result.is_liked = db.query(Like).filter( + Like.post_id == post.id, Like.user_id == current_user_id + ).first() is not None + result.is_collected = db.query(Collect).filter( + Collect.post_id == post.id, Collect.user_id == current_user_id + ).first() is not None + return result + + +@router.get("", response_model=PostListResponse) +def get_posts( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + category: Optional[str] = None, + tag: Optional[str] = None, + user_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取帖子列表""" + query = db.query(Post).filter( + or_(Post.is_public == True, Post.user_id == current_user.id), + Post.is_draft == False, + ) + if category: + query = query.filter(Post.category == category) + if tag: + query = query.filter(Post.tags.contains(tag)) + if user_id: + query = query.filter(Post.user_id == user_id) + + total = query.count() + posts = query.order_by(Post.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + return PostListResponse( + items=[_enrich_post(p, db, current_user.id) for p in posts], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post("", response_model=PostResponse) +def create_post( + data: PostCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """发布经验帖""" + post = Post( + user_id=current_user.id, + title=data.title, + content=data.content, + category=data.category, + tags=json.dumps(data.tags, ensure_ascii=False), + is_public=data.is_public, + is_draft=data.is_draft, + ) + db.add(post) + db.commit() + db.refresh(post) + return _enrich_post(post, db, current_user.id) + + +@router.get("/{post_id}", response_model=PostResponse) +def get_post( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取帖子详情""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在") + if not post.is_public and post.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权访问") + + # 增加浏览量 + post.view_count += 1 + db.commit() + db.refresh(post) + + return _enrich_post(post, db, current_user.id) + + +@router.put("/{post_id}", response_model=PostResponse) +def update_post( + post_id: int, + data: PostUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """编辑帖子""" + # 管理员可以编辑任意帖子,普通用户只能编辑自己的 + if current_user.is_admin: + post = db.query(Post).filter(Post.id == post_id).first() + else: + post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在或无权编辑") + + if data.title is not None: + post.title = data.title + if data.content is not None: + post.content = data.content + if data.category is not None: + post.category = data.category + if data.tags is not None: + post.tags = json.dumps(data.tags, ensure_ascii=False) + if data.is_public is not None: + post.is_public = data.is_public + if data.is_draft is not None: + post.is_draft = data.is_draft + + db.commit() + db.refresh(post) + return _enrich_post(post, db, current_user.id) + + +@router.delete("/{post_id}") +def delete_post( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """删除帖子""" + post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在或无权删除") + + db.query(Comment).filter(Comment.post_id == post_id).delete() + db.query(Like).filter(Like.post_id == post_id).delete() + db.query(Collect).filter(Collect.post_id == post_id).delete() + db.query(Attachment).filter(Attachment.post_id == post_id).delete() + db.delete(post) + db.commit() + return {"message": "删除成功"} + + +@router.post("/{post_id}/like") +def toggle_like( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """点赞/取消点赞""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在") + + existing = db.query(Like).filter( + Like.post_id == post_id, Like.user_id == current_user.id + ).first() + + if existing: + db.delete(existing) + post.like_count = max(0, post.like_count - 1) + db.commit() + return {"liked": False, "like_count": post.like_count} + else: + db.add(Like(post_id=post_id, user_id=current_user.id)) + post.like_count += 1 + # 通知帖子作者 + if post.user_id != current_user.id: + db.add(Notification( + user_id=post.user_id, type="like", + content=f"{current_user.username} 赞了你的文章「{post.title[:30]}」", + from_user_id=current_user.id, related_id=post_id, + )) + db.commit() + return {"liked": True, "like_count": post.like_count} + + +@router.post("/{post_id}/collect") +def toggle_collect( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """收藏/取消收藏""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在") + + existing = db.query(Collect).filter( + Collect.post_id == post_id, Collect.user_id == current_user.id + ).first() + + if existing: + db.delete(existing) + post.collect_count = max(0, post.collect_count - 1) + db.commit() + return {"collected": False, "collect_count": post.collect_count} + else: + db.add(Collect(post_id=post_id, user_id=current_user.id)) + post.collect_count += 1 + db.commit() + return {"collected": True, "collect_count": post.collect_count} + + +@router.get("/{post_id}/comments", response_model=List[CommentResponse]) +def get_comments( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取评论列表""" + comments = ( + db.query(Comment) + .filter(Comment.post_id == post_id) + .order_by(Comment.created_at.asc()) + .all() + ) + results = [] + for c in comments: + author = db.query(User).filter(User.id == c.user_id).first() + r = CommentResponse.model_validate(c) + r.author_name = author.username if author else "未知用户" + results.append(r) + return results + + +@router.post("/{post_id}/comments", response_model=CommentResponse) +def create_comment( + post_id: int, + data: CommentCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """发表评论""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在") + + comment = Comment( + post_id=post_id, + user_id=current_user.id, + content=data.content, + ) + db.add(comment) + post.comment_count += 1 + # 通知帖子作者 + if post.user_id != current_user.id: + db.add(Notification( + user_id=post.user_id, type="comment", + content=f"{current_user.username} 评论了你的文章「{post.title[:30]}」", + from_user_id=current_user.id, related_id=post_id, + )) + db.commit() + db.refresh(comment) + + result = CommentResponse.model_validate(comment) + result.author_name = current_user.username + return result + + +@router.get("/{post_id}/attachments") +def get_attachments( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取帖子的附件列表""" + attachments = ( + db.query(Attachment) + .filter(Attachment.post_id == post_id) + .order_by(Attachment.created_at.asc()) + .all() + ) + return [ + { + "id": a.id, + "filename": a.filename, + "url": a.url, + "file_size": a.file_size, + "file_type": a.file_type, + "created_at": a.created_at, + } + for a in attachments + ] + + +@router.delete("/{post_id}/attachments/{attachment_id}") +def delete_attachment( + post_id: int, + attachment_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """删除附件(仅作者)""" + attachment = db.query(Attachment).filter( + Attachment.id == attachment_id, + Attachment.post_id == post_id, + Attachment.user_id == current_user.id, + ).first() + if not attachment: + raise HTTPException(status_code=404, detail="附件不存在或无权删除") + db.delete(attachment) + db.commit() + return {"message": "删除成功"} diff --git a/backend/routers/projects.py b/backend/routers/projects.py new file mode 100644 index 0000000..ef49ea8 --- /dev/null +++ b/backend/routers/projects.py @@ -0,0 +1,410 @@ +"""开源项目路由""" +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func, or_ +from pydantic import BaseModel +from typing import Optional, List + +from database import get_db +from models.user import User +from models.project import Project +from models.like import ProjectCollect +from routers.auth import get_current_user, get_admin_user + +router = APIRouter() + +GITHUB_API = "https://api.github.com" + + +# ========== Schemas ========== + +class ProjectCreate(BaseModel): + name: str + description: str = "" + url: str + homepage: str = "" + icon: str = "" + language: str = "" + category: str = "" + stars: int = 0 + forks: int = 0 + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + url: Optional[str] = None + homepage: Optional[str] = None + icon: Optional[str] = None + language: Optional[str] = None + category: Optional[str] = None + stars: Optional[int] = None + forks: Optional[int] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +def _project_to_dict(p: Project, is_collected: bool = False) -> dict: + return { + "id": p.id, + "name": p.name, + "description": p.description or "", + "url": p.url, + "homepage": p.homepage or "", + "icon": p.icon or "", + "language": p.language or "", + "category": p.category or "", + "stars": p.stars or 0, + "forks": p.forks or 0, + "collect_count": getattr(p, 'collect_count', 0) or 0, + "is_collected": is_collected, + "sort_order": p.sort_order, + "is_active": p.is_active, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + } + + +def _with_collect_status(items: list, user_id: int, db: Session) -> list: + """批量查询用户是否已收藏""" + if not items: + return [] + project_ids = [p.id for p in items] + collected_ids = set( + r[0] for r in db.query(ProjectCollect.project_id) + .filter(ProjectCollect.project_id.in_(project_ids), ProjectCollect.user_id == user_id) + .all() + ) + return [_project_to_dict(p, p.id in collected_ids) for p in items] + + +# ========== 管理员接口 ========== + +@router.get("/admin/list") +def admin_list_projects( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + category: Optional[str] = None, + keyword: Optional[str] = None, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """获取所有项目(含禁用),支持分页""" + query = db.query(Project) + if category: + query = query.filter(Project.category == category) + if keyword: + kw = f"%{keyword}%" + query = query.filter(or_(Project.name.like(kw), Project.description.like(kw))) + total = query.count() + items = query.order_by(Project.sort_order, Project.id.desc()).offset((page - 1) * size).limit(size).all() + return { + "total": total, + "items": [_project_to_dict(p) for p in items], + } + + +@router.post("/admin") +def admin_create_project( + data: ProjectCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """新增项目""" + max_order = db.query(sa_func.max(Project.sort_order)).scalar() or 0 + proj = Project( + name=data.name, + description=data.description, + url=data.url, + homepage=data.homepage, + icon=data.icon, + language=data.language, + category=data.category, + stars=data.stars, + forks=data.forks, + sort_order=max_order + 1, + ) + db.add(proj) + db.commit() + db.refresh(proj) + return _project_to_dict(proj) + + +@router.put("/admin/{project_id}") +def admin_update_project( + project_id: int, + data: ProjectUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """编辑项目""" + proj = db.query(Project).filter(Project.id == project_id).first() + if not proj: + raise HTTPException(status_code=404, detail="项目不存在") + for field in ["name", "description", "url", "homepage", "icon", "language", "category", "stars", "forks", "sort_order", "is_active"]: + val = getattr(data, field) + if val is not None: + setattr(proj, field, val) + db.commit() + db.refresh(proj) + return _project_to_dict(proj) + + +@router.delete("/admin/{project_id}") +def admin_delete_project( + project_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """删除项目""" + proj = db.query(Project).filter(Project.id == project_id).first() + if not proj: + raise HTTPException(status_code=404, detail="项目不存在") + db.delete(proj) + db.commit() + return {"message": "删除成功"} + + +# ========== 公开接口 ========== + +@router.get("/hot") +def get_hot_projects( + page: int = Query(1, ge=1), + size: int = Query(12, ge=1, le=50), + category: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """热门项目(按 stars 降序)""" + query = db.query(Project).filter(Project.is_active == True) + if category: + query = query.filter(Project.category == category) + total = query.count() + items = query.order_by(Project.stars.desc(), Project.sort_order, Project.id.desc()).offset((page - 1) * size).limit(size).all() + return {"total": total, "items": _with_collect_status(items, current_user.id, db)} + + +@router.get("/latest") +def get_latest_projects( + page: int = Query(1, ge=1), + size: int = Query(12, ge=1, le=50), + category: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """最新项目(按创建时间降序)""" + query = db.query(Project).filter(Project.is_active == True) + if category: + query = query.filter(Project.category == category) + total = query.count() + items = query.order_by(Project.created_at.desc(), Project.id.desc()).offset((page - 1) * size).limit(size).all() + return {"total": total, "items": _with_collect_status(items, current_user.id, db)} + + +@router.get("/search") +def search_projects( + q: str = Query("", min_length=0), + page: int = Query(1, ge=1), + size: int = Query(12, ge=1, le=50), + category: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """搜索项目""" + query = db.query(Project).filter(Project.is_active == True) + if q.strip(): + kw = f"%{q.strip()}%" + query = query.filter(or_(Project.name.like(kw), Project.description.like(kw))) + if category: + query = query.filter(Project.category == category) + total = query.count() + items = query.order_by(Project.stars.desc(), Project.id.desc()).offset((page - 1) * size).limit(size).all() + return {"total": total, "items": _with_collect_status(items, current_user.id, db)} + + +@router.get("/categories") +def get_project_categories( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取所有有项目的分类""" + rows = ( + db.query(Project.category, sa_func.count(Project.id)) + .filter(Project.is_active == True, Project.category != "") + .group_by(Project.category) + .order_by(sa_func.count(Project.id).desc()) + .all() + ) + return [{"name": r[0], "count": r[1]} for r in rows] + + +# ========== 收藏接口 ========== + +@router.get("/my-collects") +def get_my_collects( + page: int = Query(1, ge=1), + size: int = Query(12, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取当前用户收藏的项目""" + subq = db.query(ProjectCollect.project_id).filter(ProjectCollect.user_id == current_user.id).subquery() + query = db.query(Project).filter(Project.id.in_(subq), Project.is_active == True) + total = query.count() + items = query.order_by(Project.id.desc()).offset((page - 1) * size).limit(size).all() + return {"total": total, "items": [_project_to_dict(p, True) for p in items]} + + +@router.post("/{project_id}/collect") +def toggle_project_collect( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """收藏/取消收藏项目""" + proj = db.query(Project).filter(Project.id == project_id).first() + if not proj: + raise HTTPException(status_code=404, detail="项目不存在") + existing = db.query(ProjectCollect).filter( + ProjectCollect.project_id == project_id, ProjectCollect.user_id == current_user.id + ).first() + if existing: + db.delete(existing) + proj.collect_count = max(0, (proj.collect_count or 0) - 1) + db.commit() + return {"collected": False, "collect_count": proj.collect_count} + else: + db.add(ProjectCollect(project_id=project_id, user_id=current_user.id)) + proj.collect_count = (proj.collect_count or 0) + 1 + db.commit() + return {"collected": True, "collect_count": proj.collect_count} + + +# ========== GitHub 搜索(公共 + 管理员通用) ========== + +async def _github_search_impl(q: str, sort: str, page: int, per_page: int): + """GitHub 搜索核心实现""" + url = f"{GITHUB_API}/search/repositories" + params = {"q": q, "sort": sort, "order": "desc", "page": page, "per_page": per_page} + headers = {"Accept": "application/vnd.github+json"} + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"GitHub API 返回 {resp.status_code}") + data = resp.json() + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="GitHub API 请求超时") + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"网络请求失败: {str(e)}") + + items = [] + for repo in data.get("items", []): + items.append({ + "github_id": repo["id"], + "name": repo.get("name", ""), + "full_name": repo.get("full_name", ""), + "description": repo.get("description") or "", + "url": repo.get("html_url", ""), + "homepage": repo.get("homepage") or "", + "icon": repo.get("owner", {}).get("avatar_url", ""), + "language": repo.get("language") or "", + "stars": repo.get("stargazers_count", 0), + "forks": repo.get("forks_count", 0), + "topics": repo.get("topics", []), + "created_at": repo.get("created_at", ""), + "updated_at": repo.get("updated_at", ""), + }) + return {"total": data.get("total_count", 0), "items": items} + + +@router.get("/github-search") +async def public_github_search( + q: str = Query(..., min_length=1), + sort: str = Query("stars"), + page: int = Query(1, ge=1), + per_page: int = Query(12, ge=1, le=30), + user: User = Depends(get_current_user), +): + """公开 GitHub 搜索(登录用户可用)""" + return await _github_search_impl(q, sort, page, per_page) + + +# ========== GitHub 导入接口(管理员) ========== + +@router.get("/admin/github-search") +async def github_search( + q: str = Query(..., min_length=1), + sort: str = Query("stars"), + page: int = Query(1, ge=1), + per_page: int = Query(12, ge=1, le=30), + admin: User = Depends(get_admin_user), +): + """管理员 GitHub 搜索""" + return await _github_search_impl(q, sort, page, per_page) + + +class GitHubImportItem(BaseModel): + name: str + description: str = "" + url: str + homepage: str = "" + icon: str = "" + language: str = "" + category: str = "" + stars: int = 0 + forks: int = 0 + + +class GitHubImportRequest(BaseModel): + items: List[GitHubImportItem] + + +@router.post("/admin/github-import") +def github_import( + data: GitHubImportRequest, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """批量导入 GitHub 项目""" + imported = 0 + skipped = 0 + max_order = db.query(sa_func.max(Project.sort_order)).scalar() or 0 + for item in data.items: + existing = db.query(Project).filter(Project.url == item.url).first() + if existing: + skipped += 1 + continue + max_order += 1 + proj = Project( + name=item.name, + description=item.description, + url=item.url, + homepage=item.homepage, + icon=item.icon, + language=item.language, + category=item.category, + stars=item.stars, + forks=item.forks, + sort_order=max_order, + ) + db.add(proj) + imported += 1 + db.commit() + return {"imported": imported, "skipped": skipped} + + +# ========== 项目详情(通配路由放最后) ========== + +@router.get("/{project_id}") +def get_project_detail( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """项目详情""" + proj = db.query(Project).filter(Project.id == project_id, Project.is_active == True).first() + if not proj: + raise HTTPException(status_code=404, detail="项目不存在") + return _project_to_dict(proj) diff --git a/backend/routers/requirement.py b/backend/routers/requirement.py new file mode 100644 index 0000000..35e40a2 --- /dev/null +++ b/backend/routers/requirement.py @@ -0,0 +1,313 @@ +"""需求理解助手路由""" +import json +import os +import uuid +from typing import List +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from database import get_db +from models.user import User +from models.conversation import Conversation, Message +from schemas.conversation import ( + RequirementAnalyzeRequest, ConversationResponse, + ConversationDetail, MessageResponse, +) +from routers.auth import get_current_user +from services.ai_service import ai_service +from config import UPLOAD_DIR + +router = APIRouter() + +REQUIREMENT_SYSTEM_PROMPT = """# 角色定义 +你同时具备两个身份: +1. **资深产品经理(10年+)**:擅长从模糊信息中提炼需求本质,精通用户故事、MECE拆解、优先级排序、验收标准制定 +2. **高级全栈程序员(10年+)**:做过大量项目,对功能的复杂度、开发工作量有精准直觉,能一眼看出哪些需求是"看似简单实则巨坑" + +你服务的对象是**程序员**,他们会把甲方发来的原始内容(口语化描述、语音转文字、截图、聊天记录、需求文档等)发给你。你需要站在「既懂业务又懂技术」的双重视角,将其转化为**清晰明确、可直接进入开发的结构化需求**。 + +> ⚠️ 本助手专注于**需求理解与分析**。技术选型、数据库设计、API设计、架构方案等请移步「架构选型AI助手」。 + +# 核心理念 +- **需求层面**:甲方说的不一定是他真正想要的,你要透过表面描述挖掘真实诉求 +- **程序员直觉**:每个功能你都要心里过一遍复杂度,标注哪些"看起来简单但实际很复杂" +- **落地导向**:不出空中楼阁式的分析,每条功能都要能对应到具体的开发任务 + +# 分析框架 +收到用户输入后,按以下框架进行系统性分析: + +## 第一步:需求还原(产品经理视角) +- 理解甲方的**核心意图**:到底想解决什么问题?服务什么业务场景? +- 识别**目标用户**是谁,使用频率、核心使用路径是什么 +- 区分"真实需求"与"表面描述",找出甲方没说但一定需要的**隐性需求** +- 判断产品定位:工具型/平台型/内容型?ToB/ToC? + +## 第二步:功能拆解(遵循 MECE 原则) +将需求拆解为相互独立、完全穷尽的功能模块,每个功能需包含: +- 功能名称 + 具体说明 +- 优先级(P0 核心必做 / P1 重要 / P2 锦上添花) +- 涉及的用户角色 +- 复杂度预判(简单/中等/复杂)+ 复杂原因说明 + +## 第三步:用户故事与验收标准 +将核心功能转写为标准用户故事: +> 作为【角色】,我希望【功能】,以便【价值/目的】 + +为 P0 功能补充验收标准(AC),采用 Given-When-Then 格式: +> 假设【前置条件】,当【用户操作】,那么【系统响应】 + +## 第四步:复杂度预警(程序员视角) +基于你做过大量项目的经验,标注: +- **隐藏复杂度**:哪些功能看似简单但实现起来有坑(如"支持实时聊天"看似一句话,实际涉及WebSocket、消息队列、已读回执等) +- **高风险功能**:涉及支付、权限、数据一致性等需要特别慎重的功能 +- **工期杀手**:容易严重超出预期工时的功能,提前预警 +- **工期粗估**:按模块给出大致工时范围(x-x天),帮助程序员评估排期 + +## 第五步:边界与待确认项 +- 需求中**含糊不清**需要甲方确认的关键问题 +- 容易遗漏的**边缘场景**(空状态、异常流、权限边界、数据量极值、并发操作) +- 甲方可能还没想到但**一定会追加**的需求(基于经验预判) + +# 输出规范 +严格使用以下 Markdown 结构输出: + +--- + +## 📋 需求概述 +> 用2-3句话概括:这是什么产品/功能,解决谁的什么问题,核心价值是什么。 +> 产品定位:【工具型/平台型/内容型】 | 【ToB/ToC】 + +## 👥 用户角色 +| 角色 | 说明 | 关键诉求 | 使用频率 | +|------|------|---------|---------| + +## 🧩 功能清单 +| 优先级 | 模块 | 功能项 | 功能说明 | 涉及角色 | 复杂度 | +|--------|------|--------|---------|---------|--------| +| P0 | xxx | xxx | xxx | xxx | 🔴复杂 - 原因 | +| P0 | xxx | xxx | xxx | xxx | 🟢简单 | +| P1 | xxx | xxx | xxx | xxx | 🟡中等 | + +## 📖 核心用户故事与验收标准 +### US-1: 【用户故事标题】 +- **故事**:作为【角色】,我希望【功能】,以便【价值】 +- **验收标准**: + - ✅ 假设【条件】,当【操作】,那么【预期结果】 + - ✅ 假设【条件】,当【异常操作】,那么【兜底处理】 + +## ⚡ 复杂度预警(程序员必看) +### 🔴 隐藏复杂度 +1. **【功能名】**:看似xxx,实际需要xxx,建议xxx + +### ⏱️ 工期粗估 +| 模块 | 工时范围 | 说明 | +|------|---------|------| +| 合计 | x-x天 | 基于1名全栈开发者,含开发+自测 | + +### 🔮 甲方大概率会追加的需求 +1. 【需求名】— 理由:基于经验,做了xxx之后甲方通常会要求xxx + +## ❓ 待确认问题 +> 以下问题会直接影响开发方案,建议优先与甲方确认: +1. **【问题】**:【为什么需要确认】→ 不确认的影响:【xxx】 + +## 💡 风险提示 +- 【业务风险、边缘场景、容易遗漏的点等】 + +--- + +> 💡 **下一步建议**:需求确认后,可将功能清单发送至「架构选型AI助手」,获取技术选型、数据库设计、API接口设计等技术方案。 + +# 交互原则 +1. **首次分析要全面**:即使信息不完整,也要基于已有信息给出最完整的分析,用待确认问题标注不确定的部分 +2. **复杂度标注要诚实**:程序员最怕"这个很简单",要如实标注复杂度和潜在的坑 +3. **追问要有价值**:只追问影响开发方案的关键问题,不问显而易见的 +4. **语言贴近程序员**:用开发者能直接理解的术语,避免纯商业话术 +5. **持续迭代**:用户补充信息后,在之前分析的基础上更新完善,而非重头开始 +6. **务实不空谈**:每条建议都基于实际经验,不说"建议优化用户体验"这类空话 +7. **工时要诚实**:给出合理区间,标注不确定因素""" + + +@router.post("/upload-image") +async def upload_image( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), +): + """上传图片""" + # 验证文件类型 + allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] + if file.content_type not in allowed_types: + raise HTTPException(status_code=400, detail="不支持的文件类型") + + # 生成唯一文件名 + ext = file.filename.split(".")[-1] if "." in file.filename else "png" + filename = f"{uuid.uuid4().hex}.{ext}" + filepath = os.path.join(UPLOAD_DIR, filename) + + # 保存文件 + content = await file.read() + with open(filepath, "wb") as f: + f.write(content) + + return {"url": f"/uploads/{filename}"} + + +@router.get("/conversations", response_model=List[ConversationResponse]) +def get_conversations( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取需求对话列表""" + conversations = ( + db.query(Conversation) + .filter(Conversation.user_id == current_user.id, Conversation.type == "requirement") + .order_by(Conversation.updated_at.desc()) + .all() + ) + return [ConversationResponse.model_validate(c) for c in conversations] + + +@router.get("/conversations/{conversation_id}", response_model=ConversationDetail) +def get_conversation_detail( + conversation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取对话详情""" + conv = db.query(Conversation).filter( + Conversation.id == conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + + messages = ( + db.query(Message) + .filter(Message.conversation_id == conversation_id) + .order_by(Message.created_at.asc()) + .all() + ) + result = ConversationDetail.model_validate(conv) + result.messages = [MessageResponse.model_validate(m) for m in messages] + return result + + +@router.post("/analyze") +async def analyze_requirement( + request: RequirementAnalyzeRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """分析需求 - 流式输出""" + # 创建或获取对话 + if request.conversation_id: + conv = db.query(Conversation).filter( + Conversation.id == request.conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + else: + conv = Conversation( + user_id=current_user.id, + title=request.content[:50] if request.content else "新需求分析", + type="requirement", + ) + db.add(conv) + db.commit() + db.refresh(conv) + + # 保存用户消息 + user_msg = Message( + conversation_id=conv.id, + role="user", + content=request.content, + image_urls=json.dumps(request.image_urls) if request.image_urls else "", + ) + db.add(user_msg) + db.commit() + + # 构建历史消息 + history_msgs = ( + db.query(Message) + .filter(Message.conversation_id == conv.id) + .order_by(Message.created_at.asc()) + .all() + ) + + messages = [] + for msg in history_msgs: + if msg.role == "user": + content = msg.content + # 如果有图片,添加图片描述提示 + if msg.image_urls: + try: + urls = json.loads(msg.image_urls) + if urls: + content += f"\n\n[用户上传了{len(urls)}张图片]" + except json.JSONDecodeError: + pass + messages.append({"role": "user", "content": content}) + else: + messages.append({"role": "assistant", "content": msg.content}) + + # 确定任务类型 + task_type = "multimodal" if request.image_urls else "reasoning" + + # 流式调用AI + async def generate(): + full_response = "" + try: + result = await ai_service.chat( + task_type=task_type, + messages=messages, + system_prompt=REQUIREMENT_SYSTEM_PROMPT, + stream=True, + model_config_id=request.model_config_id, + ) + if isinstance(result, str): + # 非流式返回 + full_response = result + yield f"data: {json.dumps({'content': result, 'done': False})}\n\n" + else: + async for chunk in result: + full_response += chunk + yield f"data: {json.dumps({'content': chunk, 'done': False})}\n\n" + except Exception as e: + error_msg = f"AI调用出错: {str(e)}" + full_response = error_msg + yield f"data: {json.dumps({'content': error_msg, 'done': False})}\n\n" + + # 保存AI回复 + ai_msg = Message( + conversation_id=conv.id, + role="assistant", + content=full_response, + ) + db.add(ai_msg) + db.commit() + + yield f"data: {json.dumps({'content': '', 'done': True, 'conversation_id': conv.id})}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + +@router.delete("/conversations/{conversation_id}") +def delete_conversation( + conversation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """删除对话""" + conv = db.query(Conversation).filter( + Conversation.id == conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + + db.query(Message).filter(Message.conversation_id == conversation_id).delete() + db.delete(conv) + db.commit() + return {"message": "删除成功"} diff --git a/backend/routers/search.py b/backend/routers/search.py new file mode 100644 index 0000000..2038296 --- /dev/null +++ b/backend/routers/search.py @@ -0,0 +1,47 @@ +"""搜索路由""" +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from sqlalchemy import or_ + +from database import get_db +from models.user import User +from models.post import Post +from schemas.post import PostResponse, PostListResponse +from routers.auth import get_current_user +from routers.posts import _enrich_post + +router = APIRouter() + + +@router.get("", response_model=PostListResponse) +def search_posts( + q: str = Query(..., min_length=1, description="搜索关键词"), + category: Optional[str] = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """全文搜索帖子""" + query = db.query(Post).filter( + or_(Post.is_public == True, Post.user_id == current_user.id), + or_( + Post.title.contains(q), + Post.content.contains(q), + Post.tags.contains(q), + ), + ) + + if category: + query = query.filter(Post.category == category) + + total = query.count() + posts = query.order_by(Post.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + return PostListResponse( + items=[_enrich_post(p, db, current_user.id) for p in posts], + total=total, + page=page, + page_size=page_size, + ) diff --git a/backend/routers/shared_api.py b/backend/routers/shared_api.py new file mode 100644 index 0000000..4cbc109 --- /dev/null +++ b/backend/routers/shared_api.py @@ -0,0 +1,526 @@ +"""共享API Hub路由""" +from fastapi import APIRouter, Depends, HTTPException, Header, Request +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func +from pydantic import BaseModel +from typing import Optional +from datetime import datetime, timedelta +import time +import hashlib + +from database import get_db +from config import SECRET_KEY +from models.user import User +from models.system_config import SystemConfig +from models.shared_api import SharedApiCategory, SharedApi, SharedApiLog +from routers.auth import get_current_user, get_admin_user + +router = APIRouter() + +# ========== 加密工具 ========== + +_fernet = None + +def _get_fernet(): + global _fernet + if _fernet is None: + from cryptography.fernet import Fernet + import base64 + # 从SECRET_KEY派生一个Fernet兼容的key + key = hashlib.sha256(SECRET_KEY.encode()).digest() + _fernet = Fernet(base64.urlsafe_b64encode(key)) + return _fernet + +def encrypt_key(plain: str) -> str: + if not plain: + return "" + return _get_fernet().encrypt(plain.encode()).decode() + +def decrypt_key(encrypted: str) -> str: + if not encrypted: + return "" + try: + return _get_fernet().decrypt(encrypted.encode()).decode() + except Exception: + return "" + +def mask_key(encrypted: str) -> str: + """脱敏显示""" + plain = decrypt_key(encrypted) + if not plain: + return "" + if len(plain) <= 8: + return plain[:2] + "***" + return plain[:4] + "****" + plain[-4:] + + +# ========== Hub访问密码机制 ========== + +def _get_hub_password(db: Session) -> str: + cfg = db.query(SystemConfig).filter(SystemConfig.key == "api_hub_password").first() + return cfg.value if cfg else "" + +def _hub_password_version(db: Session) -> str: + """返回密码哈希前8位作为版本标识,密码变更后旧token自动失效""" + pwd = _get_hub_password(db) + return pwd[:8] if pwd else "none" + +def _hash_password(pwd: str) -> str: + return hashlib.sha256(pwd.encode()).hexdigest() + +def _create_hub_token(user_id: int, pwd_ver: str = "none") -> str: + """创建Hub访问令牌(简单签名,2小时有效)""" + from jose import jwt + exp = datetime.utcnow() + timedelta(hours=2) + return jwt.encode({"sub": str(user_id), "hub": True, "pv": pwd_ver, "exp": exp}, SECRET_KEY, algorithm="HS256") + +def verify_hub_access( + x_hub_token: Optional[str] = Header(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """验证用户登录 + Hub访问令牌""" + if not x_hub_token: + raise HTTPException(status_code=403, detail="需要API Hub访问权限,请先验证密码") + from jose import jwt, JWTError + try: + payload = jwt.decode(x_hub_token, SECRET_KEY, algorithms=["HS256"]) + if not payload.get("hub"): + raise HTTPException(status_code=403, detail="无效的Hub令牌") + # 检查密码版本是否匹配 + token_pv = payload.get("pv", "") + current_pv = _hub_password_version(db) + if token_pv != current_pv: + raise HTTPException(status_code=403, detail="密码已变更,请重新验证") + except JWTError: + raise HTTPException(status_code=403, detail="Hub令牌已过期,请重新验证密码") + return current_user + + +# ========== Schemas ========== + +class HubAuthRequest(BaseModel): + password: str + +class CategoryCreate(BaseModel): + name: str + icon: str = "" + +class CategoryUpdate(BaseModel): + name: Optional[str] = None + icon: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + +class ApiCreate(BaseModel): + category_id: Optional[int] = None + name: str + description: str = "" + base_url: str = "" + doc_url: str = "" + auth_type: str = "none" + api_key: str = "" # 明文传入,后端加密存储 + api_key_header: str = "Authorization" + health_check_url: str = "" + tags: str = "" + +class ApiUpdate(BaseModel): + category_id: Optional[int] = None + name: Optional[str] = None + description: Optional[str] = None + base_url: Optional[str] = None + doc_url: Optional[str] = None + auth_type: Optional[str] = None + api_key: Optional[str] = None + api_key_header: Optional[str] = None + health_check_url: Optional[str] = None + tags: Optional[str] = None + is_active: Optional[bool] = None + +class ApiTestRequest(BaseModel): + method: str = "GET" + path: str = "" + body: str = "" + headers: dict = {} + + +# ========== 密码认证接口 ========== + +@router.post("/auth") +def hub_auth( + data: HubAuthRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """验证Hub访问密码""" + stored = _get_hub_password(db) + if not stored: + raise HTTPException(status_code=400, detail="管理员尚未设置访问密码") + if _hash_password(data.password) != stored: + raise HTTPException(status_code=403, detail="密码错误") + token = _create_hub_token(user.id, _hub_password_version(db)) + return {"hub_token": token, "expires_in": 7200} + +@router.get("/check-password") +def check_password_set( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """检查是否已设置访问密码""" + stored = _get_hub_password(db) + return {"has_password": bool(stored)} + +@router.put("/admin/password") +def set_hub_password( + data: HubAuthRequest, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """管理员设置Hub访问密码""" + if len(data.password) < 4: + raise HTTPException(status_code=400, detail="密码至少4位") + hashed = _hash_password(data.password) + cfg = db.query(SystemConfig).filter(SystemConfig.key == "api_hub_password").first() + if cfg: + cfg.value = hashed + else: + cfg = SystemConfig(key="api_hub_password", value=hashed, description="API Hub访问密码") + db.add(cfg) + db.commit() + return {"message": "密码设置成功"} + +@router.get("/admin/password-status") +def get_password_status( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user), +): + """管理员查看密码是否已设置""" + stored = _get_hub_password(db) + return {"has_password": bool(stored)} + + +# ========== 分类管理 ========== + +@router.get("/categories") +def list_categories( + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + cats = db.query(SharedApiCategory).order_by(SharedApiCategory.sort_order, SharedApiCategory.id).all() + return [ + {"id": c.id, "name": c.name, "icon": c.icon, "sort_order": c.sort_order, "is_active": c.is_active, + "api_count": db.query(sa_func.count(SharedApi.id)).filter(SharedApi.category_id == c.id).scalar() or 0} + for c in cats + ] + +@router.post("/categories") +def create_category( + data: CategoryCreate, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + existing = db.query(SharedApiCategory).filter(SharedApiCategory.name == data.name).first() + if existing: + raise HTTPException(status_code=400, detail="分类名称已存在") + max_order = db.query(sa_func.max(SharedApiCategory.sort_order)).scalar() or 0 + cat = SharedApiCategory(name=data.name, icon=data.icon, sort_order=max_order + 1) + db.add(cat) + db.commit() + db.refresh(cat) + return {"id": cat.id, "name": cat.name, "icon": cat.icon} + +@router.put("/categories/{cat_id}") +def update_category( + cat_id: int, + data: CategoryUpdate, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + cat = db.query(SharedApiCategory).filter(SharedApiCategory.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + if data.name is not None: + cat.name = data.name + if data.icon is not None: + cat.icon = data.icon + if data.sort_order is not None: + cat.sort_order = data.sort_order + if data.is_active is not None: + cat.is_active = data.is_active + db.commit() + return {"message": "更新成功"} + +@router.delete("/categories/{cat_id}") +def delete_category( + cat_id: int, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + cat = db.query(SharedApiCategory).filter(SharedApiCategory.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + # 将该分类下的API设为未分类 + db.query(SharedApi).filter(SharedApi.category_id == cat_id).update({SharedApi.category_id: None}) + db.delete(cat) + db.commit() + return {"message": "删除成功"} + + +# ========== API CRUD ========== + +def _api_to_dict(api, db=None): + d = { + "id": api.id, "category_id": api.category_id, + "name": api.name, "description": api.description, + "base_url": api.base_url, "doc_url": api.doc_url, + "auth_type": api.auth_type, + "api_key_masked": mask_key(api.api_key_encrypted), + "api_key_plain": decrypt_key(api.api_key_encrypted) if api.api_key_encrypted else "", + "has_api_key": bool(api.api_key_encrypted), + "api_key_header": api.api_key_header, + "health_check_url": api.health_check_url, + "last_check_time": api.last_check_time.isoformat() if api.last_check_time else None, + "last_check_status": api.last_check_status, + "added_by": api.added_by, "tags": api.tags, + "call_count": api.call_count, "is_active": api.is_active, + "created_at": api.created_at.isoformat() if api.created_at else None, + "updated_at": api.updated_at.isoformat() if api.updated_at else None, + } + return d + +@router.get("/list") +def list_apis( + keyword: Optional[str] = None, + category_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + query = db.query(SharedApi).filter(SharedApi.is_active == True) + if keyword: + kw = f"%{keyword}%" + query = query.filter( + (SharedApi.name.like(kw)) | (SharedApi.description.like(kw)) | (SharedApi.tags.like(kw)) + ) + if category_id is not None: + query = query.filter(SharedApi.category_id == category_id) + apis = query.order_by(SharedApi.call_count.desc(), SharedApi.id.desc()).all() + return {"items": [_api_to_dict(a) for a in apis], "total": len(apis)} + +@router.post("/") +def create_api( + data: ApiCreate, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + api = SharedApi( + category_id=data.category_id, name=data.name, description=data.description, + base_url=data.base_url, doc_url=data.doc_url, + auth_type=data.auth_type, + api_key_encrypted=encrypt_key(data.api_key) if data.api_key else "", + api_key_header=data.api_key_header, + health_check_url=data.health_check_url, + tags=data.tags, added_by=user.id, + ) + db.add(api) + db.commit() + db.refresh(api) + return _api_to_dict(api) + +@router.put("/{api_id}") +def update_api( + api_id: int, + data: ApiUpdate, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + api = db.query(SharedApi).filter(SharedApi.id == api_id).first() + if not api: + raise HTTPException(status_code=404, detail="API不存在") + if data.category_id is not None: + api.category_id = data.category_id + if data.name is not None: + api.name = data.name + if data.description is not None: + api.description = data.description + if data.base_url is not None: + api.base_url = data.base_url + if data.doc_url is not None: + api.doc_url = data.doc_url + if data.auth_type is not None: + api.auth_type = data.auth_type + if data.api_key is not None and data.api_key != "": + api.api_key_encrypted = encrypt_key(data.api_key) + if data.api_key_header is not None: + api.api_key_header = data.api_key_header + if data.health_check_url is not None: + api.health_check_url = data.health_check_url + if data.tags is not None: + api.tags = data.tags + if data.is_active is not None: + api.is_active = data.is_active + db.commit() + db.refresh(api) + return _api_to_dict(api) + +@router.delete("/{api_id}") +def delete_api( + api_id: int, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + api = db.query(SharedApi).filter(SharedApi.id == api_id).first() + if not api: + raise HTTPException(status_code=404, detail="API不存在") + db.query(SharedApiLog).filter(SharedApiLog.api_id == api_id).delete() + db.delete(api) + db.commit() + return {"message": "删除成功"} + + +# ========== API测试 ========== + +@router.post("/{api_id}/test") +async def test_api( + api_id: int, + data: ApiTestRequest, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + """在线测试API(后端代理请求)""" + api = db.query(SharedApi).filter(SharedApi.id == api_id).first() + if not api: + raise HTTPException(status_code=404, detail="API不存在") + + url = api.base_url.rstrip("/") + if data.path: + url = url + "/" + data.path.lstrip("/") + + headers = dict(data.headers) if data.headers else {} + # 注入认证信息 + if api.auth_type != "none" and api.api_key_encrypted: + key = decrypt_key(api.api_key_encrypted) + if key: + if api.auth_type == "bearer": + headers[api.api_key_header] = f"Bearer {key}" + elif api.auth_type == "api_key": + headers[api.api_key_header] = key + elif api.auth_type == "basic": + import base64 + headers["Authorization"] = f"Basic {base64.b64encode(key.encode()).decode()}" + + import httpx + start = time.time() + try: + async with httpx.AsyncClient(timeout=15) as client: + if data.method.upper() == "POST": + resp = await client.post(url, headers=headers, content=data.body or None) + elif data.method.upper() == "PUT": + resp = await client.put(url, headers=headers, content=data.body or None) + elif data.method.upper() == "DELETE": + resp = await client.delete(url, headers=headers) + else: + resp = await client.get(url, headers=headers) + elapsed = int((time.time() - start) * 1000) + # 记录日志 + log = SharedApiLog( + api_id=api_id, user_id=user.id, action="test", + request_url=url, response_status=resp.status_code, response_time_ms=elapsed, + ) + db.add(log) + api.call_count = (api.call_count or 0) + 1 + db.commit() + # 限制返回体大小 + body = resp.text[:5000] if len(resp.text) > 5000 else resp.text + return { + "status_code": resp.status_code, + "response_time_ms": elapsed, + "headers": dict(resp.headers), + "body": body, + } + except Exception as e: + elapsed = int((time.time() - start) * 1000) + log = SharedApiLog( + api_id=api_id, user_id=user.id, action="test", + request_url=url, response_status=0, response_time_ms=elapsed, + ) + db.add(log) + db.commit() + return {"status_code": 0, "response_time_ms": elapsed, "headers": {}, "body": str(e)} + + +@router.post("/{api_id}/health-check") +async def health_check( + api_id: int, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + """健康检查""" + api = db.query(SharedApi).filter(SharedApi.id == api_id).first() + if not api: + raise HTTPException(status_code=404, detail="API不存在") + + check_url = api.health_check_url or api.base_url + import httpx + start = time.time() + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(check_url) + elapsed = int((time.time() - start) * 1000) + status = "ok" if resp.status_code < 400 else "error" + except Exception: + elapsed = int((time.time() - start) * 1000) + status = "error" + + api.last_check_time = datetime.utcnow() + api.last_check_status = status + log = SharedApiLog( + api_id=api_id, user_id=user.id, action="health_check", + request_url=check_url, response_status=resp.status_code if status == "ok" else 0, + response_time_ms=elapsed, + ) + db.add(log) + db.commit() + return {"status": status, "response_time_ms": elapsed} + + +# ========== 日志与统计 ========== + +@router.get("/{api_id}/logs") +def get_api_logs( + api_id: int, + limit: int = 20, + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + logs = ( + db.query(SharedApiLog) + .filter(SharedApiLog.api_id == api_id) + .order_by(SharedApiLog.id.desc()) + .limit(limit) + .all() + ) + return [ + { + "id": l.id, "action": l.action, "request_url": l.request_url, + "response_status": l.response_status, "response_time_ms": l.response_time_ms, + "user_id": l.user_id, + "created_at": l.created_at.isoformat() if l.created_at else None, + } + for l in logs + ] + +@router.get("/stats") +def get_stats( + db: Session = Depends(get_db), + user: User = Depends(verify_hub_access), +): + total_apis = db.query(sa_func.count(SharedApi.id)).filter(SharedApi.is_active == True).scalar() or 0 + total_calls = db.query(sa_func.sum(SharedApi.call_count)).scalar() or 0 + total_categories = db.query(sa_func.count(SharedApiCategory.id)).scalar() or 0 + healthy = db.query(sa_func.count(SharedApi.id)).filter(SharedApi.last_check_status == "ok").scalar() or 0 + return { + "total_apis": total_apis, + "total_calls": total_calls, + "total_categories": total_categories, + "healthy_count": healthy, + } diff --git a/backend/routers/upload.py b/backend/routers/upload.py new file mode 100644 index 0000000..fb9a119 --- /dev/null +++ b/backend/routers/upload.py @@ -0,0 +1,206 @@ +"""通用文件上传路由 - 腾讯云COS""" +import uuid +import os +from datetime import datetime +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException +from sqlalchemy.orm import Session +from routers.auth import get_current_user +from models.user import User +from models.system_config import SystemConfig +from database import get_db +from config import MAX_UPLOAD_SIZE, MAX_ATTACHMENT_SIZE, UPLOAD_DIR +from models.attachment import Attachment + +router = APIRouter() + +ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] + +ALLOWED_ATTACHMENT_TYPES = [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/zip", + "application/x-zip-compressed", + "application/x-rar-compressed", + "application/vnd.rar", +] +ALLOWED_ATTACHMENT_EXTS = { + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "zip", "rar", +} + + +def _get_cos_config(db: Session) -> dict: + """从数据库读取COS配置""" + keys = ["cos_secret_id", "cos_secret_key", "cos_bucket", "cos_region", "cos_custom_domain"] + config = {} + for k in keys: + row = db.query(SystemConfig).filter(SystemConfig.key == k).first() + config[k] = row.value if row else "" + return config + + +def _get_cos_client(db: Session): + """获取COS客户端实例,未配置则返回None""" + config = _get_cos_config(db) + secret_id = config.get("cos_secret_id", "") + secret_key = config.get("cos_secret_key", "") + bucket = config.get("cos_bucket", "") + region = config.get("cos_region", "") + if not all([secret_id, secret_key, bucket, region]): + return None, config + try: + from qcloud_cos import CosConfig, CosS3Client + cos_config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key) + client = CosS3Client(cos_config) + return client, config + except ImportError: + return None, config + + +def _build_cos_url(config: dict, object_key: str) -> str: + """构建COS访问URL""" + custom_domain = config.get("cos_custom_domain", "") + if custom_domain: + return f"https://{custom_domain}/{object_key}" + bucket = config.get("cos_bucket", "") + region = config.get("cos_region", "") + return f"https://{bucket}.cos.{region}.myqcloud.com/{object_key}" + + +@router.post("/image") +async def upload_image( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """上传图片,优先OSS,未配置则本地存储""" + # 验证文件类型 + if file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="不支持的文件类型,仅支持 JPG/PNG/GIF/WEBP") + + # 读取文件内容 + content = await file.read() + + # 验证文件大小 + if len(content) > MAX_UPLOAD_SIZE: + raise HTTPException(status_code=400, detail=f"文件过大,最大支持 {MAX_UPLOAD_SIZE // (1024*1024)}MB") + + # 生成唯一文件名 + ext = file.filename.split(".")[-1].lower() if "." in file.filename else "png" + date_prefix = datetime.now().strftime("%Y/%m") + filename = f"{uuid.uuid4().hex}.{ext}" + object_key = f"images/{date_prefix}/{filename}" + + # 尝试COS上传(从数据库读取配置) + client, config = _get_cos_client(db) + if client: + try: + client.put_object( + Bucket=config.get("cos_bucket", ""), + Body=content, + Key=object_key, + ContentType=file.content_type, + ) + url = _build_cos_url(config, object_key) + return {"url": url, "storage": "cos"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"COS上传失败: {str(e)}") + else: + # 降级到本地存储 + os.makedirs(UPLOAD_DIR, exist_ok=True) + filepath = os.path.join(UPLOAD_DIR, filename) + with open(filepath, "wb") as f: + f.write(content) + return {"url": f"/uploads/{filename}", "storage": "local"} + + +@router.post("/attachment") +async def upload_attachment( + file: UploadFile = File(...), + post_id: int = 0, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """上传附件到COS,支持 PDF/Word/Excel/PPT/ZIP/RAR""" + # 验证文件扩展名 + ext = file.filename.split(".")[-1].lower() if "." in file.filename else "" + if ext not in ALLOWED_ATTACHMENT_EXTS: + raise HTTPException( + status_code=400, + detail=f"不支持的文件类型,仅支持: {', '.join(sorted(ALLOWED_ATTACHMENT_EXTS))}", + ) + + # 读取文件内容 + content = await file.read() + if len(content) > MAX_ATTACHMENT_SIZE: + raise HTTPException( + status_code=400, + detail=f"文件过大,最大支持 {MAX_ATTACHMENT_SIZE // (1024*1024)}MB", + ) + + # 生成唯一文件名 + date_prefix = datetime.now().strftime("%Y/%m") + unique_name = f"{uuid.uuid4().hex}.{ext}" + object_key = f"attachments/{date_prefix}/{unique_name}" + + # 上传到COS + client, config = _get_cos_client(db) + if not client: + raise HTTPException(status_code=500, detail="对象存储未配置,无法上传附件") + + try: + client.put_object( + Bucket=config.get("cos_bucket", ""), + Body=content, + Key=object_key, + ContentType=file.content_type or "application/octet-stream", + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"COS上传失败: {str(e)}") + + url = _build_cos_url(config, object_key) + + # 写入数据库 + attachment = Attachment( + post_id=post_id if post_id else None, + user_id=current_user.id, + filename=file.filename, + storage_key=object_key, + url=url, + file_size=len(content), + file_type=file.content_type or "application/octet-stream", + ) + db.add(attachment) + db.commit() + db.refresh(attachment) + + return { + "id": attachment.id, + "filename": attachment.filename, + "url": attachment.url, + "file_size": attachment.file_size, + "file_type": attachment.file_type, + } + + +@router.put("/attachment/{attachment_id}/post") +async def update_attachment_post( + attachment_id: int, + post_id: int = 0, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """将附件关联到帖子(新建帖子后回填 post_id)""" + attachment = db.query(Attachment).filter( + Attachment.id == attachment_id, + Attachment.user_id == current_user.id, + ).first() + if not attachment: + raise HTTPException(status_code=404, detail="附件不存在") + attachment.post_id = post_id + db.commit() + return {"message": "ok"} diff --git a/backend/routers/users.py b/backend/routers/users.py new file mode 100644 index 0000000..49d19f6 --- /dev/null +++ b/backend/routers/users.py @@ -0,0 +1,214 @@ +"""用户主页和关注系统路由""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func +from database import get_db +from models.user import User +from models.post import Post +from models.follow import Follow +from models.like import Collect +from models.notification import Notification +from routers.auth import get_current_user + +router = APIRouter() + + +@router.get("/{user_id}") +def get_user_profile( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取用户主页信息""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + post_count = db.query(func.count(Post.id)).filter(Post.user_id == user_id, Post.is_public == True).scalar() + follower_count = db.query(func.count(Follow.id)).filter(Follow.following_id == user_id).scalar() + following_count = db.query(func.count(Follow.id)).filter(Follow.follower_id == user_id).scalar() + is_following = db.query(Follow).filter( + Follow.follower_id == current_user.id, Follow.following_id == user_id + ).first() is not None + + return { + "id": user.id, + "username": user.username, + "email": user.email, + "avatar": user.avatar, + "created_at": user.created_at, + "post_count": post_count, + "follower_count": follower_count, + "following_count": following_count, + "is_following": is_following, + "is_self": current_user.id == user_id, + } + + +@router.post("/{user_id}/follow") +def toggle_follow( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """关注/取消关注""" + if current_user.id == user_id: + raise HTTPException(status_code=400, detail="不能关注自己") + + target = db.query(User).filter(User.id == user_id).first() + if not target: + raise HTTPException(status_code=404, detail="用户不存在") + + existing = db.query(Follow).filter( + Follow.follower_id == current_user.id, Follow.following_id == user_id + ).first() + + if existing: + db.delete(existing) + db.commit() + return {"followed": False} + else: + follow = Follow(follower_id=current_user.id, following_id=user_id) + db.add(follow) + # 创建通知 + notif = Notification( + user_id=user_id, + type="follow", + content=f"{current_user.username} 关注了你", + from_user_id=current_user.id, + related_id=current_user.id, + ) + db.add(notif) + db.commit() + return {"followed": True} + + +@router.get("/{user_id}/posts") +def get_user_posts( + user_id: int, + page: int = 1, + page_size: int = 20, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取用户的帖子列表""" + query = db.query(Post).filter(Post.user_id == user_id) + if user_id != current_user.id: + query = query.filter(Post.is_public == True) + posts = query.order_by(Post.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + user = db.query(User).filter(User.id == user_id).first() + username = user.username if user else "未知" + avatar = user.avatar if user else "" + + return [ + { + "id": p.id, "title": p.title, "content": p.content[:200], + "category": p.category, "tags": p.tags, + "view_count": p.view_count, "like_count": p.like_count, + "comment_count": p.comment_count, "collect_count": p.collect_count, + "created_at": p.created_at, "updated_at": p.updated_at, + "author": {"id": user_id, "username": username, "avatar": avatar}, + } + for p in posts + ] + + +@router.get("/{user_id}/followers") +def get_followers( + user_id: int, + page: int = 1, + page_size: int = 20, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取粉丝列表""" + follows = ( + db.query(Follow) + .filter(Follow.following_id == user_id) + .order_by(Follow.created_at.desc()) + .offset((page - 1) * page_size).limit(page_size).all() + ) + user_ids = [f.follower_id for f in follows] + users = db.query(User).filter(User.id.in_(user_ids)).all() if user_ids else [] + user_map = {u.id: u for u in users} + + return [ + { + "id": uid, + "username": user_map[uid].username if uid in user_map else "", + "avatar": user_map[uid].avatar if uid in user_map else "", + } + for uid in user_ids + ] + + +@router.get("/{user_id}/following") +def get_following( + user_id: int, + page: int = 1, + page_size: int = 20, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取关注列表""" + follows = ( + db.query(Follow) + .filter(Follow.follower_id == user_id) + .order_by(Follow.created_at.desc()) + .offset((page - 1) * page_size).limit(page_size).all() + ) + user_ids = [f.following_id for f in follows] + users = db.query(User).filter(User.id.in_(user_ids)).all() if user_ids else [] + user_map = {u.id: u for u in users} + + return [ + { + "id": uid, + "username": user_map[uid].username if uid in user_map else "", + "avatar": user_map[uid].avatar if uid in user_map else "", + } + for uid in user_ids + ] + + +@router.get("/{user_id}/collects") +def get_user_collects( + user_id: int, + page: int = 1, + page_size: int = 20, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取用户收藏的帖子""" + collects = ( + db.query(Collect) + .filter(Collect.user_id == user_id) + .order_by(Collect.created_at.desc()) + .offset((page - 1) * page_size).limit(page_size).all() + ) + post_ids = [c.post_id for c in collects] + if not post_ids: + return [] + + posts = db.query(Post).filter(Post.id.in_(post_ids)).all() + post_map = {p.id: p for p in posts} + author_ids = list(set(p.user_id for p in posts)) + authors = db.query(User).filter(User.id.in_(author_ids)).all() + author_map = {a.id: a for a in authors} + + result = [] + for pid in post_ids: + p = post_map.get(pid) + if not p: + continue + a = author_map.get(p.user_id) + result.append({ + "id": p.id, "title": p.title, "content": p.content[:200], + "category": p.category, "tags": p.tags, + "view_count": p.view_count, "like_count": p.like_count, + "comment_count": p.comment_count, "collect_count": p.collect_count, + "created_at": p.created_at, + "author": {"id": p.user_id, "username": a.username if a else "", "avatar": a.avatar if a else ""}, + }) + return result diff --git a/backend/routers/web_search.py b/backend/routers/web_search.py new file mode 100644 index 0000000..0a6ff19 --- /dev/null +++ b/backend/routers/web_search.py @@ -0,0 +1,274 @@ +"""联网搜索路由 - 使用豆包大模型 + 火山方舟 web_search""" +import json +import httpx +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from database import get_db +from models.user import User +from models.conversation import Conversation, Message +from schemas.conversation import ConversationResponse, ConversationDetail, MessageResponse +from routers.auth import get_current_user +from config import ARK_API_KEY, ARK_ENDPOINT, ARK_BASE_URL + +router = APIRouter() + +WEB_SEARCH_SYSTEM_PROMPT = """你是一个智能联网搜索助手。你可以通过联网搜索获取最新信息来回答用户的问题。 + +回答要求: +1. 基于搜索到的最新信息给出准确、详细的回答 +2. 使用清晰的 Markdown 格式组织内容 +3. 如果涉及时效性信息,注明信息的时间 +4. 对搜索结果进行整合和总结,而非简单罗列 +5. 如果搜索结果不足以回答问题,诚实告知并给出建议""" + + +class WebSearchRequest(BaseModel): + conversation_id: Optional[int] = None + content: str + model_config_id: Optional[int] = None + + +@router.post("/search") +async def web_search( + request: WebSearchRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """联网搜索 - 流式输出""" + # 获取模型配置:优先用指定的,否则用 .env 的 ARK 配置 + from services.ai_service import _get_db_model_config + ark_api_key = ARK_API_KEY + ark_endpoint = ARK_ENDPOINT + ark_base_url = ARK_BASE_URL + search_count = 5 + + if request.model_config_id: + cfg = _get_db_model_config("", request.model_config_id) + if cfg: + ark_api_key = cfg["api_key"] + ark_endpoint = cfg["model"] + ark_base_url = cfg["base_url"] or ARK_BASE_URL + search_count = cfg.get("web_search_count", 5) + + if not ark_api_key: + raise HTTPException(status_code=500, detail="未配置火山方舟 API Key") + + # 创建或获取对话 + if request.conversation_id: + conv = db.query(Conversation).filter( + Conversation.id == request.conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + else: + conv = Conversation( + user_id=current_user.id, + title=request.content[:50] if request.content else "新搜索", + type="web_search", + ) + db.add(conv) + db.commit() + db.refresh(conv) + + # 保存用户消息 + user_msg = Message( + conversation_id=conv.id, + role="user", + content=request.content, + ) + db.add(user_msg) + db.commit() + + # 构建历史消息 + history_msgs = ( + db.query(Message) + .filter(Message.conversation_id == conv.id) + .order_by(Message.created_at.asc()) + .all() + ) + + messages = [{"role": "system", "content": WEB_SEARCH_SYSTEM_PROMPT}] + for msg in history_msgs: + messages.append({"role": msg.role, "content": msg.content}) + + # 流式调用火山方舟 API + url = f"{ark_base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {ark_api_key}", + "Content-Type": "application/json", + } + + + async def generate(): + full_response = "" + try: + payload_with_search = { + "model": ark_endpoint, + "messages": messages, + "stream": True, + "tools": [ + { + "type": "web_search", + "web_search": {"enable": True, "search_result_count": max(1, min(50, search_count))}, + } + ], + } + payload_without_search = { + "model": ark_endpoint, + "messages": messages, + "stream": True, + } + + # 先尝试带联网搜索调用 + use_fallback = False + async with httpx.AsyncClient(timeout=120.0) as client: + async with client.stream("POST", url, json=payload_with_search, headers=headers) as resp: + if resp.status_code == 400: + # 模型不支持 web_search tools,降级到普通调用 + await resp.aread() + use_fallback = True + elif resp.status_code != 200: + error_body = await resp.aread() + error_msg = f"API 调用失败 ({resp.status_code}): {error_body.decode()}" + yield f"data: {json.dumps({'content': error_msg, 'done': False})}\n\n" + full_response = error_msg + else: + buffer = "" + async for chunk in resp.aiter_text(): + buffer += chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line or not line.startswith("data: "): + continue + data_str = line[6:] + if data_str == "[DONE]": + continue + try: + data = json.loads(data_str) + choices = data.get("choices", []) + if choices: + delta = choices[0].get("delta", {}) + content = delta.get("content", "") + if content: + full_response += content + yield f"data: {json.dumps({'content': content, 'done': False})}\n\n" + except json.JSONDecodeError: + pass + + # 降级:不带 web_search tools 重试 + if use_fallback: + async with httpx.AsyncClient(timeout=120.0) as client: + async with client.stream("POST", url, json=payload_without_search, headers=headers) as resp: + if resp.status_code != 200: + error_body = await resp.aread() + error_msg = f"API 调用失败 ({resp.status_code}): {error_body.decode()}" + yield f"data: {json.dumps({'content': error_msg, 'done': False})}\n\n" + full_response = error_msg + else: + buffer = "" + async for chunk in resp.aiter_text(): + buffer += chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line or not line.startswith("data: "): + continue + data_str = line[6:] + if data_str == "[DONE]": + continue + try: + data = json.loads(data_str) + choices = data.get("choices", []) + if choices: + delta = choices[0].get("delta", {}) + content = delta.get("content", "") + if content: + full_response += content + yield f"data: {json.dumps({'content': content, 'done': False})}\n\n" + except json.JSONDecodeError: + pass + except Exception as e: + error_msg = f"联网搜索出错: {str(e)}" + if not full_response: + full_response = error_msg + yield f"data: {json.dumps({'content': error_msg, 'done': False})}\n\n" + + # 保存AI回复 + if full_response: + ai_msg = Message( + conversation_id=conv.id, + role="assistant", + content=full_response, + ) + db.add(ai_msg) + db.commit() + + yield f"data: {json.dumps({'content': '', 'done': True, 'conversation_id': conv.id})}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + +@router.get("/conversations", response_model=List[ConversationResponse]) +def get_conversations( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取联网搜索对话列表""" + conversations = ( + db.query(Conversation) + .filter(Conversation.user_id == current_user.id, Conversation.type == "web_search") + .order_by(Conversation.updated_at.desc()) + .all() + ) + return [ConversationResponse.model_validate(c) for c in conversations] + + +@router.get("/conversations/{conversation_id}", response_model=ConversationDetail) +def get_conversation_detail( + conversation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取对话详情""" + conv = db.query(Conversation).filter( + Conversation.id == conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + + msgs = ( + db.query(Message) + .filter(Message.conversation_id == conversation_id) + .order_by(Message.created_at.asc()) + .all() + ) + result = ConversationDetail.model_validate(conv) + result.messages = [MessageResponse.model_validate(m) for m in msgs] + return result + + +@router.delete("/conversations/{conversation_id}") +def delete_conversation( + conversation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """删除对话""" + conv = db.query(Conversation).filter( + Conversation.id == conversation_id, + Conversation.user_id == current_user.id, + ).first() + if not conv: + raise HTTPException(status_code=404, detail="对话不存在") + + db.query(Message).filter(Message.conversation_id == conversation_id).delete() + db.delete(conv) + db.commit() + return {"message": "删除成功"} diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/schemas/ai_model.py b/backend/schemas/ai_model.py new file mode 100644 index 0000000..1c3884a --- /dev/null +++ b/backend/schemas/ai_model.py @@ -0,0 +1,62 @@ +"""AI模型配置Schema""" +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List + + +class AIModelCreate(BaseModel): + provider: str + provider_name: str = "" + model_id: str + model_name: str = "" + api_key: str = "" + base_url: str = "" + task_type: str = "" + is_enabled: bool = True + is_default: bool = False + web_search_enabled: bool = False + web_search_count: int = 5 # 联网搜索结果条数,1-50 + description: str = "" + + +class AIModelUpdate(BaseModel): + provider_name: Optional[str] = None + model_id: Optional[str] = None + model_name: Optional[str] = None + api_key: Optional[str] = None + base_url: Optional[str] = None + task_type: Optional[str] = None + is_enabled: Optional[bool] = None + is_default: Optional[bool] = None + web_search_enabled: Optional[bool] = None + web_search_count: Optional[int] = None + description: Optional[str] = None + + +class AIModelResponse(BaseModel): + id: int + provider: str + provider_name: str = "" + model_id: str + model_name: str = "" + api_key_masked: str = "" # 脱敏后的API Key + base_url: str = "" + task_type: str = "" + is_enabled: bool = True + is_default: bool = False + web_search_enabled: bool = False + web_search_count: int = 5 + description: str = "" + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProviderInfo(BaseModel): + """服务商信息""" + provider: str + name: str + models: List[dict] + default_base_url: str = "" diff --git a/backend/schemas/bookmark.py b/backend/schemas/bookmark.py new file mode 100644 index 0000000..0369125 --- /dev/null +++ b/backend/schemas/bookmark.py @@ -0,0 +1,38 @@ +"""网站收藏Schema""" +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List + + +class BookmarkCreate(BaseModel): + name: str + url: str + icon: str = "" + + +class BookmarkUpdate(BaseModel): + name: Optional[str] = None + url: Optional[str] = None + icon: Optional[str] = None + sort_order: Optional[int] = None + + +class BookmarkResponse(BaseModel): + id: int + name: str + url: str + icon: str = "" + sort_order: int = 0 + created_at: datetime + + class Config: + from_attributes = True + + +class ReorderItem(BaseModel): + id: int + sort_order: int + + +class ReorderRequest(BaseModel): + items: List[ReorderItem] diff --git a/backend/schemas/conversation.py b/backend/schemas/conversation.py new file mode 100644 index 0000000..b4f1da2 --- /dev/null +++ b/backend/schemas/conversation.py @@ -0,0 +1,55 @@ +"""对话相关Schema""" +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List + + +class MessageCreate(BaseModel): + content: str + image_urls: List[str] = [] + + +class MessageResponse(BaseModel): + id: int + conversation_id: int + role: str + content: str + image_urls: str = "" + created_at: datetime + + class Config: + from_attributes = True + + +class ConversationCreate(BaseModel): + type: str # requirement / architecture + title: str = "新对话" + + +class ConversationResponse(BaseModel): + id: int + user_id: int + title: str + type: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ConversationDetail(ConversationResponse): + messages: List[MessageResponse] = [] + + +class RequirementAnalyzeRequest(BaseModel): + conversation_id: Optional[int] = None + content: str + image_urls: List[str] = [] + model_config_id: Optional[int] = None + + +class ArchitectureRequest(BaseModel): + conversation_id: Optional[int] = None + content: str + model_config_id: Optional[int] = None diff --git a/backend/schemas/post.py b/backend/schemas/post.py new file mode 100644 index 0000000..ec4bce9 --- /dev/null +++ b/backend/schemas/post.py @@ -0,0 +1,69 @@ +"""帖子相关Schema""" +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List + + +class PostCreate(BaseModel): + title: str + content: str + category: str = "" + tags: List[str] = [] + is_public: bool = True + is_draft: bool = False + + +class PostUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + category: Optional[str] = None + tags: Optional[List[str]] = None + is_public: Optional[bool] = None + is_draft: Optional[bool] = None + + +class PostResponse(BaseModel): + id: int + user_id: int + title: str + content: str + category: str = "" + tags: str = "" + is_public: bool = True + is_draft: bool = False + view_count: int = 0 + like_count: int = 0 + collect_count: int = 0 + comment_count: int = 0 + created_at: datetime + updated_at: datetime + # 额外字段(查询时填充) + author_name: str = "" + is_liked: bool = False + is_collected: bool = False + + class Config: + from_attributes = True + + +class PostListResponse(BaseModel): + items: List[PostResponse] + total: int + page: int + page_size: int + + +class CommentCreate(BaseModel): + content: str + + +class CommentResponse(BaseModel): + id: int + post_id: int + user_id: int + content: str + created_at: datetime + author_name: str = "" + + class Config: + from_attributes = True diff --git a/backend/schemas/user.py b/backend/schemas/user.py new file mode 100644 index 0000000..b56a066 --- /dev/null +++ b/backend/schemas/user.py @@ -0,0 +1,43 @@ +"""用户相关Schema""" +from pydantic import BaseModel, EmailStr +from datetime import datetime +from typing import Optional + + +class UserRegister(BaseModel): + username: str + email: str + password: str + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + avatar: str = "" + is_admin: bool = False + is_banned: bool = False + is_approved: bool = False + created_at: datetime + + class Config: + from_attributes = True + + +class UserUpdate(BaseModel): + username: Optional[str] = None + email: Optional[str] = None + avatar: Optional[str] = None + old_password: Optional[str] = None + new_password: Optional[str] = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py new file mode 100644 index 0000000..884bf41 --- /dev/null +++ b/backend/services/ai_service.py @@ -0,0 +1,429 @@ +"""统一大模型调用服务 - 支持多模型路由和流式输出""" +import json +import httpx +import asyncio +from typing import AsyncGenerator, List, Optional, Union +from openai import AsyncOpenAI +from anthropic import AsyncAnthropic + +from config import ( + MODEL_CONFIG, + OPENAI_API_KEY, + ANTHROPIC_API_KEY, + GOOGLE_API_KEY, + DEEPSEEK_API_KEY, + ARK_API_KEY, + ARK_BASE_URL, +) + + +def _get_db_model_config(task_type: str, model_config_id: int = None): + """从数据库获取指定任务类型的默认模型配置,或指定 ID 的模型""" + try: + from database import SessionLocal + from models.ai_model import AIModelConfig + db = SessionLocal() + try: + # 如果指定了模型 ID,直接用该模型 + if model_config_id: + model = db.query(AIModelConfig).filter( + AIModelConfig.id == model_config_id, + AIModelConfig.is_enabled == True, + ).first() + if model and model.api_key: + return { + "provider": model.provider, + "model": model.model_id, + "api_key": model.api_key, + "base_url": model.base_url, + "web_search_enabled": model.web_search_enabled, + "web_search_count": model.web_search_count or 5, + } + # 否则找默认模型 + model = db.query(AIModelConfig).filter( + AIModelConfig.task_type == task_type, + AIModelConfig.is_default == True, + AIModelConfig.is_enabled == True, + ).first() + if model and model.api_key: + return { + "provider": model.provider, + "model": model.model_id, + "api_key": model.api_key, + "base_url": model.base_url, + "web_search_enabled": model.web_search_enabled, + "web_search_count": model.web_search_count or 5, + } + # 没有默认的,找任意一个启用且有Key的 + model = db.query(AIModelConfig).filter( + AIModelConfig.task_type == task_type, + AIModelConfig.is_enabled == True, + AIModelConfig.api_key != "", + ).first() + if model: + return { + "provider": model.provider, + "model": model.model_id, + "api_key": model.api_key, + "base_url": model.base_url, + "web_search_enabled": model.web_search_enabled, + "web_search_count": model.web_search_count or 5, + } + finally: + db.close() + except Exception: + pass + return None + + +class AIService: + """统一AI服务,根据任务类型路由到不同大模型""" + + def __init__(self): + # OpenAI客户端(也用于DeepSeek等兼容API) + if OPENAI_API_KEY: + self.openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) + else: + self.openai_client = None + + # Anthropic客户端 + if ANTHROPIC_API_KEY: + self.anthropic_client = AsyncAnthropic(api_key=ANTHROPIC_API_KEY) + else: + self.anthropic_client = None + + # DeepSeek客户端(兼容OpenAI接口) + if DEEPSEEK_API_KEY: + self.deepseek_client = AsyncOpenAI( + api_key=DEEPSEEK_API_KEY, + base_url="https://api.deepseek.com/v1", + ) + else: + self.deepseek_client = None + + def _get_client_for_provider(self, provider: str, api_key: str, base_url: str = ""): + """根据provider动态创建客户端""" + if provider == "anthropic": + return AsyncAnthropic(api_key=api_key) + # openai/deepseek/google 都用OpenAI兼容接口 + kwargs = {"api_key": api_key} + if base_url: + kwargs["base_url"] = base_url + return AsyncOpenAI(**kwargs) + + async def chat( + self, + task_type: str, + messages: List[dict], + system_prompt: str = "", + stream: bool = False, + model_config_id: int = None, + ) -> Union[str, AsyncGenerator[str, None]]: + """ + 统一对话接口 + + 参数: + task_type: 任务类型 (multimodal/reasoning/lightweight) + messages: 消息列表 [{"role": "user", "content": "..."}] + system_prompt: 系统提示词 + stream: 是否流式输出 + model_config_id: 指定模型配置ID(可选,不传则用默认) + """ + # 优先从数据库读取模型配置 + db_config = _get_db_model_config(task_type, model_config_id) + if db_config: + provider = db_config["provider"] + model = db_config["model"] + api_key = db_config["api_key"] + base_url = db_config["base_url"] + web_search = db_config.get("web_search_enabled", False) + web_search_count = db_config.get("web_search_count", 5) + # 火山方舟/豆包 + 联网搜索(开启后自动使用,失败则降级到普通调用) + if provider == "ark" and web_search: + try: + return await self._chat_ark_web_search(api_key, base_url, model, messages, system_prompt, stream, web_search_count) + except Exception: + # 联网搜索调用失败,降级到普通调用 + pass + # 火山方舟/豆包 不带联网搜索(OpenAI 兼容接口) + if provider == "ark": + kwargs = {"api_key": api_key} + if base_url: + kwargs["base_url"] = base_url + else: + kwargs["base_url"] = ARK_BASE_URL + client = AsyncOpenAI(**kwargs) + return await self._chat_openai(client, model, messages, system_prompt, stream) + if provider == "anthropic": + client = AsyncAnthropic(api_key=api_key) + return await self._chat_anthropic_with_client(client, model, messages, system_prompt, stream) + else: + kwargs = {"api_key": api_key} + if base_url: + kwargs["base_url"] = base_url + client = AsyncOpenAI(**kwargs) + # deepseek-reasoner 需要特殊处理 + if model == "deepseek-reasoner": + return await self._chat_deepseek_reasoner(client, model, messages, system_prompt, stream) + return await self._chat_openai(client, model, messages, system_prompt, stream) + + # 回退到 .env 配置 + config = MODEL_CONFIG.get(task_type, MODEL_CONFIG["reasoning"]) + provider = config["provider"] + model = config["model"] + + if provider == "anthropic" and self.anthropic_client: + return await self._chat_anthropic(model, messages, system_prompt, stream) + elif provider == "openai" and self.openai_client: + return await self._chat_openai(self.openai_client, model, messages, system_prompt, stream) + elif provider == "deepseek" and self.deepseek_client: + return await self._chat_openai(self.deepseek_client, model, messages, system_prompt, stream) + elif provider == "google": + if GOOGLE_API_KEY: + google_client = AsyncOpenAI( + api_key=GOOGLE_API_KEY, + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + ) + return await self._chat_openai(google_client, model, messages, system_prompt, stream) + + # 降级 + if self.deepseek_client: + return await self._chat_openai(self.deepseek_client, "deepseek-chat", messages, system_prompt, stream) + if self.openai_client: + return await self._chat_openai(self.openai_client, "gpt-4o-mini", messages, system_prompt, stream) + if self.anthropic_client: + return await self._chat_anthropic("claude-sonnet-4-20250514", messages, system_prompt, stream) + + return "未配置任何AI模型,请到「模型管理」页面配置模型和API Key。" + + async def _chat_ark_web_search( + self, api_key: str, base_url: str, model: str, + messages: List[dict], system_prompt: str, stream: bool, + search_count: int = 5, + ) -> Union[str, AsyncGenerator[str, None]]: + """火山方舟 + 联网搜索(使用 httpx 直接调用,因 web_search 是非标准 tools 类型)""" + url = f"{base_url or ARK_BASE_URL}/chat/completions" + full_messages = [] + if system_prompt: + full_messages.append({"role": "system", "content": system_prompt}) + full_messages.extend(messages) + + # 限制搜索条数范围 1-50 + search_count = max(1, min(50, search_count or 5)) + payload = { + "model": model, + "messages": full_messages, + "stream": stream, + "tools": [{"type": "web_search", "web_search": {"enable": True, "search_result_count": search_count}}], + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + if not stream: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code != 200: + # 联网搜索调用失败,抛出异常以便降级到普通调用 + raise Exception(f"API调用失败 ({resp.status_code}): {resp.text[:200]}") + data = resp.json() + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + else: + # 流式调用时,先发一个预检测请求确认模型支持 web_search + async with httpx.AsyncClient(timeout=10.0) as client: + test_payload = { + "model": model, + "messages": [{"role": "user", "content": "test"}], + "stream": False, + "max_tokens": 1, + "tools": [{"type": "web_search", "web_search": {"enable": True, "search_result_count": 1}}], + } + try: + resp = await client.post(url, json=test_payload, headers=headers) + if resp.status_code == 400: + # 模型不支持 web_search,抛出异常以便降级 + raise Exception("模型不支持联网搜索") + except httpx.TimeoutException: + pass # 超时不影响,继续尝试流式调用 + return self._stream_ark_web_search(url, payload, headers) + + async def _stream_ark_web_search( + self, url: str, payload: dict, headers: dict, + ) -> AsyncGenerator[str, None]: + """火山方舟联网搜索流式输出""" + async with httpx.AsyncClient(timeout=120.0) as client: + async with client.stream("POST", url, json=payload, headers=headers) as resp: + if resp.status_code != 200: + error_body = await resp.aread() + yield f"API调用失败 ({resp.status_code}): {error_body.decode()[:200]}" + return + buffer = "" + async for chunk in resp.aiter_text(): + buffer += chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line or not line.startswith("data: "): + continue + data_str = line[6:] + if data_str == "[DONE]": + continue + try: + data = json.loads(data_str) + choices = data.get("choices", []) + if choices: + delta = choices[0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + except json.JSONDecodeError: + pass + + async def _chat_openai( + self, client: AsyncOpenAI, model: str, messages: List[dict], + system_prompt: str, stream: bool, + ) -> Union[str, AsyncGenerator[str, None]]: + """OpenAI兼容接口调用""" + full_messages = [] + if system_prompt: + full_messages.append({"role": "system", "content": system_prompt}) + full_messages.extend(messages) + + if stream: + return self._stream_openai(client, model, full_messages) + else: + response = await client.chat.completions.create( + model=model, + messages=full_messages, + temperature=0.7, + max_tokens=4096, + ) + return response.choices[0].message.content + + async def _stream_openai( + self, client: AsyncOpenAI, model: str, messages: List[dict], + ) -> AsyncGenerator[str, None]: + """OpenAI流式输出""" + response = await client.chat.completions.create( + model=model, + messages=messages, + temperature=0.7, + max_tokens=4096, + stream=True, + ) + async for chunk in response: + if chunk.choices[0].delta.content: + yield chunk.choices[0].delta.content + + async def _chat_deepseek_reasoner( + self, client: AsyncOpenAI, model: str, messages: List[dict], + system_prompt: str, stream: bool, + ) -> Union[str, AsyncGenerator[str, None]]: + """DeepSeek Reasoner (思考模式) 专用调用 + + 注意:deepseek-reasoner 不支持 temperature/top_p/system 等参数 + 输出包含 reasoning_content(思考过程)和 content(最终回答) + """ + # reasoner 不支持 system role,将 system prompt 合并到第一条用户消息 + full_messages = [] + for msg in messages: + full_messages.append(msg) + if system_prompt and full_messages: + first_user = None + for m in full_messages: + if m["role"] == "user": + first_user = m + break + if first_user: + first_user["content"] = f"[指令] {system_prompt}\n\n[用户输入] {first_user['content']}" + + if stream: + return self._stream_deepseek_reasoner(client, model, full_messages) + else: + response = await client.chat.completions.create( + model=model, + messages=full_messages, + max_tokens=8192, + ) + reasoning = getattr(response.choices[0].message, 'reasoning_content', '') or '' + content = response.choices[0].message.content or '' + if reasoning: + return f"\n{reasoning}\n\n\n{content}" + return content + + async def _stream_deepseek_reasoner( + self, client: AsyncOpenAI, model: str, messages: List[dict], + ) -> AsyncGenerator[str, None]: + """DeepSeek Reasoner 流式输出 - 包含思考过程和最终回答""" + response = await client.chat.completions.create( + model=model, + messages=messages, + max_tokens=8192, + stream=True, + ) + in_reasoning = False + reasoning_started = False + async for chunk in response: + delta = chunk.choices[0].delta + # 思考过程 + reasoning_content = getattr(delta, 'reasoning_content', None) + if reasoning_content: + if not reasoning_started: + reasoning_started = True + in_reasoning = True + yield "
\n💭 思考过程\n\n" + yield reasoning_content + # 最终回答 + if delta.content: + if in_reasoning: + in_reasoning = False + yield "\n
\n\n" + yield delta.content + + async def _chat_anthropic( + self, model: str, messages: List[dict], + system_prompt: str, stream: bool, + ) -> Union[str, AsyncGenerator[str, None]]: + """Anthropic接口调用(使用self.anthropic_client)""" + return await self._chat_anthropic_with_client(self.anthropic_client, model, messages, system_prompt, stream) + + async def _chat_anthropic_with_client( + self, client, model: str, messages: List[dict], + system_prompt: str, stream: bool, + ) -> Union[str, AsyncGenerator[str, None]]: + """Anthropic接口调用""" + if stream: + return self._stream_anthropic_with_client(client, model, messages, system_prompt) + else: + response = await client.messages.create( + model=model, + max_tokens=4096, + system=system_prompt if system_prompt else "You are a helpful assistant.", + messages=messages, + ) + return response.content[0].text + + async def _stream_anthropic( + self, model: str, messages: List[dict], system_prompt: str, + ) -> AsyncGenerator[str, None]: + """Anthropic流式输出(使用self.anthropic_client)""" + async for text in self._stream_anthropic_with_client(self.anthropic_client, model, messages, system_prompt): + yield text + + async def _stream_anthropic_with_client( + self, client, model: str, messages: List[dict], system_prompt: str, + ) -> AsyncGenerator[str, None]: + """Anthropic流式输出""" + async with client.messages.stream( + model=model, + max_tokens=4096, + system=system_prompt if system_prompt else "You are a helpful assistant.", + messages=messages, + ) as stream: + async for text in stream.text_stream: + yield text + + +# 单例 +ai_service = AIService() diff --git a/backend/uploads/.gitkeep b/backend/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/biancheng_full.sql b/biancheng_full.sql new file mode 100644 index 0000000..1a16b45 --- /dev/null +++ b/biancheng_full.sql @@ -0,0 +1,841 @@ +-- MySQL dump 10.13 Distrib 8.0.32, for Linux (aarch64) +-- +-- Host: localhost Database: biancheng +-- ------------------------------------------------------ +-- Server version 8.0.32 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Current Database: `biancheng` +-- + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `biancheng` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; + +USE `biancheng`; + +-- +-- Table structure for table `ai_model_configs` +-- + +DROP TABLE IF EXISTS `ai_model_configs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ai_model_configs` ( + `id` int NOT NULL AUTO_INCREMENT, + `provider` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `provider_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `model_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `model_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `api_key` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `base_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `task_type` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_enabled` tinyint(1) DEFAULT NULL, + `is_default` tinyint(1) DEFAULT NULL, + `description` text COLLATE utf8mb4_unicode_ci, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP, + `web_search_enabled` tinyint(1) NOT NULL DEFAULT '0', + `web_search_count` int NOT NULL DEFAULT '5', + PRIMARY KEY (`id`), + KEY `ix_ai_model_configs_id` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `ai_model_configs` +-- + +LOCK TABLES `ai_model_configs` WRITE; +/*!40000 ALTER TABLE `ai_model_configs` DISABLE KEYS */; +INSERT INTO `ai_model_configs` VALUES (1,'deepseek','DeepSeek','deepseek-reasoner','DeepSeek-V3.2 思考','sk-90c52d90b6ee44969dffb00e41be531d','https://api.deepseek.com','reasoning',1,1,'DeepSeek-V3.2 思考模式,带推理链输出','2026-04-02 03:19:15','2026-04-11 10:24:34',0,5),(2,'deepseek','DeepSeek','deepseek-reasoner','DeepSeek-V3.2 思考','sk-90c52d90b6ee44969dffb00e41be531d','https://api.deepseek.com','lightweight',1,0,'DeepSeek-V3.2 思考模式,带推理链输出','2026-04-02 03:19:31','2026-04-02 03:19:31',0,5),(3,'ark','火山方舟(豆包)','ep-20260411180700-z6nll','Doubao-Seed-2.0-pro','8aa0e6ca-731b-48bd-adaa-791a083a7b5d','https://ark.cn-beijing.volces.com/api/v3','reasoning',1,0,'豆包旗舰模型,支持联网搜索','2026-04-11 10:23:54','2026-04-11 10:45:49',1,30); +/*!40000 ALTER TABLE `ai_model_configs` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `attachments` +-- + +DROP TABLE IF EXISTS `attachments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `attachments` ( + `id` int NOT NULL AUTO_INCREMENT, + `post_id` int NOT NULL, + `user_id` int NOT NULL, + `filename` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `storage_key` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `file_size` bigint NOT NULL, + `file_type` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + KEY `ix_attachments_post_id` (`post_id`), + KEY `ix_attachments_id` (`id`), + CONSTRAINT `attachments_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`), + CONSTRAINT `attachments_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `attachments` +-- + +LOCK TABLES `attachments` WRITE; +/*!40000 ALTER TABLE `attachments` DISABLE KEYS */; +/*!40000 ALTER TABLE `attachments` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `bookmark_sites` +-- + +DROP TABLE IF EXISTS `bookmark_sites`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `bookmark_sites` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `icon` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sort_order` int DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `ix_bookmark_sites_id` (`id`), + KEY `ix_bookmark_sites_user_id` (`user_id`), + CONSTRAINT `bookmark_sites_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `bookmark_sites` +-- + +LOCK TABLES `bookmark_sites` WRITE; +/*!40000 ALTER TABLE `bookmark_sites` DISABLE KEYS */; +INSERT INTO `bookmark_sites` VALUES (1,1,'py服务器','https://139.199.162.170:9777/sgcode','',0,'2026-04-05 03:13:49'),(2,1,'生财有术','https://scys.com/','',1,'2026-04-11 06:16:51'); +/*!40000 ALTER TABLE `bookmark_sites` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `categories` +-- + +DROP TABLE IF EXISTS `categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `categories` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `sort_order` int DEFAULT NULL, + `is_active` tinyint(1) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + KEY `ix_categories_id` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `categories` +-- + +LOCK TABLES `categories` WRITE; +/*!40000 ALTER TABLE `categories` DISABLE KEYS */; +INSERT INTO `categories` VALUES (1,'前端',0,1),(2,'后端',1,1),(3,'部署',2,1),(4,'踩坑',3,1),(5,'最佳实践',4,1),(6,'工具',5,1); +/*!40000 ALTER TABLE `categories` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `collects` +-- + +DROP TABLE IF EXISTS `collects`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `collects` ( + `id` int NOT NULL AUTO_INCREMENT, + `post_id` int NOT NULL, + `user_id` int NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_collect_post_user` (`post_id`,`user_id`), + KEY `user_id` (`user_id`), + KEY `ix_collects_post_id` (`post_id`), + KEY `ix_collects_id` (`id`), + CONSTRAINT `collects_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`), + CONSTRAINT `collects_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `collects` +-- + +LOCK TABLES `collects` WRITE; +/*!40000 ALTER TABLE `collects` DISABLE KEYS */; +INSERT INTO `collects` VALUES (1,1,3,'2026-04-11 10:59:24'); +/*!40000 ALTER TABLE `collects` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `comments` +-- + +DROP TABLE IF EXISTS `comments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `comments` ( + `id` int NOT NULL AUTO_INCREMENT, + `post_id` int NOT NULL, + `user_id` int NOT NULL, + `content` text COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + KEY `ix_comments_id` (`id`), + KEY `ix_comments_post_id` (`post_id`), + CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`), + CONSTRAINT `comments_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `comments` +-- + +LOCK TABLES `comments` WRITE; +/*!40000 ALTER TABLE `comments` DISABLE KEYS */; +INSERT INTO `comments` VALUES (1,1,1,'1','2026-04-11 05:55:58'); +/*!40000 ALTER TABLE `comments` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `conversations` +-- + +DROP TABLE IF EXISTS `conversations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `conversations` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `ix_conversations_user_id` (`user_id`), + KEY `ix_conversations_id` (`id`), + CONSTRAINT `conversations_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `conversations` +-- + +LOCK TABLES `conversations` WRITE; +/*!40000 ALTER TABLE `conversations` DISABLE KEYS */; +/*!40000 ALTER TABLE `conversations` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `follows` +-- + +DROP TABLE IF EXISTS `follows`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `follows` ( + `id` int NOT NULL AUTO_INCREMENT, + `follower_id` int NOT NULL, + `following_id` int NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_follow` (`follower_id`,`following_id`), + KEY `ix_follows_follower_id` (`follower_id`), + KEY `ix_follows_following_id` (`following_id`), + KEY `ix_follows_id` (`id`), + CONSTRAINT `follows_ibfk_1` FOREIGN KEY (`follower_id`) REFERENCES `users` (`id`), + CONSTRAINT `follows_ibfk_2` FOREIGN KEY (`following_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `follows` +-- + +LOCK TABLES `follows` WRITE; +/*!40000 ALTER TABLE `follows` DISABLE KEYS */; +INSERT INTO `follows` VALUES (1,3,1,'2026-04-11 10:59:20'); +/*!40000 ALTER TABLE `follows` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `kb_access_logs` +-- + +DROP TABLE IF EXISTS `kb_access_logs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `kb_access_logs` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int DEFAULT NULL, + `action` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `query` text COLLATE utf8mb4_unicode_ci, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + KEY `ix_kb_access_logs_id` (`id`), + CONSTRAINT `kb_access_logs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `kb_access_logs` +-- + +LOCK TABLES `kb_access_logs` WRITE; +/*!40000 ALTER TABLE `kb_access_logs` DISABLE KEYS */; +/*!40000 ALTER TABLE `kb_access_logs` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `kb_categories` +-- + +DROP TABLE IF EXISTS `kb_categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `kb_categories` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `icon` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sort_order` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + KEY `ix_kb_categories_id` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `kb_categories` +-- + +LOCK TABLES `kb_categories` WRITE; +/*!40000 ALTER TABLE `kb_categories` DISABLE KEYS */; +/*!40000 ALTER TABLE `kb_categories` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `kb_items` +-- + +DROP TABLE IF EXISTS `kb_items`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `kb_items` ( + `id` int NOT NULL AUTO_INCREMENT, + `category_id` int DEFAULT NULL, + `post_id` int NOT NULL, + `title` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL, + `summary` text COLLATE utf8mb4_unicode_ci, + `sort_order` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL, + `added_by` int DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `added_by` (`added_by`), + KEY `ix_kb_items_category_id` (`category_id`), + KEY `ix_kb_items_id` (`id`), + KEY `ix_kb_items_post_id` (`post_id`), + CONSTRAINT `kb_items_ibfk_1` FOREIGN KEY (`category_id`) REFERENCES `kb_categories` (`id`), + CONSTRAINT `kb_items_ibfk_2` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`), + CONSTRAINT `kb_items_ibfk_3` FOREIGN KEY (`added_by`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `kb_items` +-- + +LOCK TABLES `kb_items` WRITE; +/*!40000 ALTER TABLE `kb_items` DISABLE KEYS */; +/*!40000 ALTER TABLE `kb_items` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `likes` +-- + +DROP TABLE IF EXISTS `likes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `likes` ( + `id` int NOT NULL AUTO_INCREMENT, + `post_id` int NOT NULL, + `user_id` int NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_like_post_user` (`post_id`,`user_id`), + KEY `user_id` (`user_id`), + KEY `ix_likes_id` (`id`), + KEY `ix_likes_post_id` (`post_id`), + CONSTRAINT `likes_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`), + CONSTRAINT `likes_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `likes` +-- + +LOCK TABLES `likes` WRITE; +/*!40000 ALTER TABLE `likes` DISABLE KEYS */; +INSERT INTO `likes` VALUES (1,1,1,'2026-04-11 05:54:48'),(2,1,3,'2026-04-11 10:59:24'); +/*!40000 ALTER TABLE `likes` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `messages` +-- + +DROP TABLE IF EXISTS `messages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `messages` ( + `id` int NOT NULL AUTO_INCREMENT, + `conversation_id` int NOT NULL, + `role` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `content` text COLLATE utf8mb4_unicode_ci NOT NULL, + `image_urls` text COLLATE utf8mb4_unicode_ci, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `ix_messages_conversation_id` (`conversation_id`), + KEY `ix_messages_id` (`id`), + CONSTRAINT `messages_ibfk_1` FOREIGN KEY (`conversation_id`) REFERENCES `conversations` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `messages` +-- + +LOCK TABLES `messages` WRITE; +/*!40000 ALTER TABLE `messages` DISABLE KEYS */; +/*!40000 ALTER TABLE `messages` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `nav_categories` +-- + +DROP TABLE IF EXISTS `nav_categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `nav_categories` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `icon` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sort_order` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `ix_nav_categories_id` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `nav_categories` +-- + +LOCK TABLES `nav_categories` WRITE; +/*!40000 ALTER TABLE `nav_categories` DISABLE KEYS */; +INSERT INTO `nav_categories` VALUES (1,'Agent Skills','',1,1,'2026-04-11 10:50:25'),(2,'其他','',2,1,'2026-04-11 10:51:56'),(3,'AI 编程工具','',3,1,'2026-04-11 10:55:57'),(4,'AI 大模型','',4,1,'2026-04-11 10:55:57'),(5,'开发者社区','',5,1,'2026-04-11 10:55:57'),(6,'前端开发','',6,1,'2026-04-11 10:55:57'),(7,'后端 & 框架','',7,1,'2026-04-11 10:55:57'),(8,'数据库 & 存储','',8,1,'2026-04-11 10:55:57'),(9,'DevOps & 部署','',9,1,'2026-04-11 10:55:57'),(10,'设计 & UI','',10,1,'2026-04-11 10:55:57'),(11,'API & 开发工具','',11,1,'2026-04-11 10:55:57'),(12,'学习 & 教程','',12,1,'2026-04-11 10:55:57'); +/*!40000 ALTER TABLE `nav_categories` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `nav_links` +-- + +DROP TABLE IF EXISTS `nav_links`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `nav_links` ( + `id` int NOT NULL AUTO_INCREMENT, + `category_id` int NOT NULL, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `icon` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sort_order` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `status` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'approved', + `submitted_by` int DEFAULT NULL, + `reject_reason` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '', + PRIMARY KEY (`id`), + KEY `ix_nav_links_category_id` (`category_id`), + KEY `ix_nav_links_id` (`id`), + CONSTRAINT `nav_links_ibfk_1` FOREIGN KEY (`category_id`) REFERENCES `nav_categories` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=98 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `nav_links` +-- + +LOCK TABLES `nav_links` WRITE; +/*!40000 ALTER TABLE `nav_links` DISABLE KEYS */; +INSERT INTO `nav_links` VALUES (1,1,'qoder技能市场','https://qoder.com/marketplace','','',0,1,'2026-04-11 10:50:50','approved',1,''),(2,3,'GitHub Copilot','https://github.com/features/copilot','https://github.githubassets.com/favicons/favicon.svg','GitHub 官方 AI 编程助手,支持多种 IDE',1,1,'2026-04-11 10:55:57','approved',NULL,''),(3,3,'Cursor','https://cursor.com','https://cursor.com/favicon.ico','AI 驱动的代码编辑器,内置 AI 编程助手',2,1,'2026-04-11 10:55:57','approved',NULL,''),(4,3,'Qoder','https://qoder.com','','智能 AI 编程助手,高效代码生成与辅助',3,1,'2026-04-11 10:55:57','approved',NULL,''),(5,3,'通义灵码','https://tongyi.aliyun.com/lingma','','阿里云 AI 编程助手,支持代码生成与补全',4,1,'2026-04-11 10:55:57','approved',NULL,''),(6,3,'Claude Code','https://docs.anthropic.com/en/docs/claude-code/overview','','Anthropic 出品的终端 AI 编程工具',5,1,'2026-04-11 10:55:57','approved',NULL,''),(7,3,'Windsurf','https://windsurf.com','','AI 代码编辑器,流畅的 AI 协作编程体验',6,1,'2026-04-11 10:55:57','approved',NULL,''),(8,3,'Trae','https://trae.ai','','字节跳动推出的 AI IDE,免费使用',7,1,'2026-04-11 10:55:57','approved',NULL,''),(9,3,'V0','https://v0.dev','','Vercel 出品的 AI 前端代码生成工具',8,1,'2026-04-11 10:55:57','approved',NULL,''),(10,3,'Bolt.new','https://bolt.new','','AI 全栈开发平台,浏览器中直接构建应用',9,1,'2026-04-11 10:55:57','approved',NULL,''),(11,4,'ChatGPT','https://chat.openai.com','','OpenAI 旗舰对话模型,全球最流行的 AI 助手',1,1,'2026-04-11 10:55:57','approved',NULL,''),(12,4,'Claude','https://claude.ai','','Anthropic 出品,擅长长文本理解和代码生成',2,1,'2026-04-11 10:55:57','approved',NULL,''),(13,4,'DeepSeek','https://chat.deepseek.com','','国产高性价比推理模型,代码能力优秀',3,1,'2026-04-11 10:55:57','approved',NULL,''),(14,4,'豆包','https://www.doubao.com','','字节跳动 AI 助手,支持联网搜索',4,1,'2026-04-11 10:55:57','approved',NULL,''),(15,4,'Gemini','https://gemini.google.com','','Google 多模态 AI 模型',5,1,'2026-04-11 10:55:57','approved',NULL,''),(16,4,'Kimi','https://kimi.moonshot.cn','','月之暗面出品,擅长长文本处理',6,1,'2026-04-11 10:55:57','approved',NULL,''),(17,4,'通义千问','https://tongyi.aliyun.com','','阿里云大模型,多模态能力强',7,1,'2026-04-11 10:55:57','approved',NULL,''),(18,4,'智谱清言','https://chatglm.cn','','清华系大模型,中文理解能力出色',8,1,'2026-04-11 10:55:57','approved',NULL,''),(19,5,'GitHub','https://github.com','https://github.githubassets.com/favicons/favicon.svg','全球最大的代码托管和开源社区',1,1,'2026-04-11 10:55:57','approved',NULL,''),(20,5,'Stack Overflow','https://stackoverflow.com','','全球最大的编程问答社区',2,1,'2026-04-11 10:55:57','approved',NULL,''),(21,5,'稀土掘金','https://juejin.cn','','国内优质技术社区,前后端文章丰富',3,1,'2026-04-11 10:55:57','approved',NULL,''),(22,5,'CSDN','https://www.csdn.net','','中文 IT 技术社区,覆盖面广',4,1,'2026-04-11 10:55:57','approved',NULL,''),(23,5,'博客园','https://www.cnblogs.com','','老牌技术博客平台,.NET 生态丰富',5,1,'2026-04-11 10:55:57','approved',NULL,''),(24,5,'思否 SegmentFault','https://segmentfault.com','','中文技术问答与分享社区',6,1,'2026-04-11 10:55:57','approved',NULL,''),(25,5,'V2EX','https://www.v2ex.com','','创意工作者社区,技术话题活跃',7,1,'2026-04-11 10:55:57','approved',NULL,''),(26,5,'Hacker News','https://news.ycombinator.com','','Y Combinator 旗下科技资讯社区',8,1,'2026-04-11 10:55:57','approved',NULL,''),(27,5,'Dev.to','https://dev.to','','国际开发者博客平台,内容质量高',9,1,'2026-04-11 10:55:57','approved',NULL,''),(28,5,'Reddit Programming','https://www.reddit.com/r/programming/','','Reddit 编程频道,全球开发者讨论',10,1,'2026-04-11 10:55:57','approved',NULL,''),(29,6,'MDN Web Docs','https://developer.mozilla.org/zh-CN/','','Mozilla 官方 Web 技术文档,前端必备参考',1,1,'2026-04-11 10:55:57','approved',NULL,''),(30,6,'Can I Use','https://caniuse.com','','浏览器兼容性查询工具',2,1,'2026-04-11 10:55:57','approved',NULL,''),(31,6,'Vue.js','https://vuejs.org','','渐进式 JavaScript 框架官方文档',3,1,'2026-04-11 10:55:57','approved',NULL,''),(32,6,'React','https://react.dev','','Meta 出品的 UI 构建库官方文档',4,1,'2026-04-11 10:55:57','approved',NULL,''),(33,6,'Next.js','https://nextjs.org','','React 全栈框架,支持 SSR/SSG',5,1,'2026-04-11 10:55:57','approved',NULL,''),(34,6,'Tailwind CSS','https://tailwindcss.com','','原子化 CSS 框架,快速构建 UI',6,1,'2026-04-11 10:55:57','approved',NULL,''),(35,6,'TypeScript','https://www.typescriptlang.org','','JavaScript 的超集,添加类型系统',7,1,'2026-04-11 10:55:57','approved',NULL,''),(36,6,'Vite','https://vitejs.dev','','下一代前端构建工具,极速开发体验',8,1,'2026-04-11 10:55:57','approved',NULL,''),(37,6,'npm','https://www.npmjs.com','','JavaScript 包管理器和注册表',9,1,'2026-04-11 10:55:57','approved',NULL,''),(38,7,'FastAPI','https://fastapi.tiangolo.com','','高性能 Python Web 框架,自动生成 API 文档',1,1,'2026-04-11 10:55:57','approved',NULL,''),(39,7,'Django','https://www.djangoproject.com','','Python 全功能 Web 框架',2,1,'2026-04-11 10:55:57','approved',NULL,''),(40,7,'Flask','https://flask.palletsprojects.com','','Python 轻量级 Web 框架',3,1,'2026-04-11 10:55:57','approved',NULL,''),(41,7,'Spring Boot','https://spring.io/projects/spring-boot','','Java 企业级框架,微服务首选',4,1,'2026-04-11 10:55:57','approved',NULL,''),(42,7,'Express.js','https://expressjs.com','','Node.js 最流行的 Web 框架',5,1,'2026-04-11 10:55:57','approved',NULL,''),(43,7,'Go 官网','https://go.dev','','Google 出品的高性能编程语言',6,1,'2026-04-11 10:55:57','approved',NULL,''),(44,7,'Rust 官网','https://www.rust-lang.org','','安全高效的系统编程语言',7,1,'2026-04-11 10:55:57','approved',NULL,''),(45,7,'Node.js','https://nodejs.org','','JavaScript 服务端运行时',8,1,'2026-04-11 10:55:57','approved',NULL,''),(46,8,'MySQL','https://www.mysql.com','','最流行的开源关系型数据库',1,1,'2026-04-11 10:55:57','approved',NULL,''),(47,8,'PostgreSQL','https://www.postgresql.org','','功能最强大的开源关系型数据库',2,1,'2026-04-11 10:55:57','approved',NULL,''),(48,8,'Redis','https://redis.io','','高性能内存数据库,缓存首选',3,1,'2026-04-11 10:55:57','approved',NULL,''),(49,8,'MongoDB','https://www.mongodb.com','','最流行的文档型 NoSQL 数据库',4,1,'2026-04-11 10:55:57','approved',NULL,''),(50,8,'Supabase','https://supabase.com','','开源 Firebase 替代品,PostgreSQL 云服务',5,1,'2026-04-11 10:55:57','approved',NULL,''),(51,8,'PlanetScale','https://planetscale.com','','MySQL 兼容的 Serverless 数据库平台',6,1,'2026-04-11 10:55:57','approved',NULL,''),(52,9,'Docker Hub','https://hub.docker.com','','容器镜像仓库,Docker 官方平台',1,1,'2026-04-11 10:55:57','approved',NULL,''),(53,9,'Docker 文档','https://docs.docker.com','','Docker 官方文档和教程',2,1,'2026-04-11 10:55:57','approved',NULL,''),(54,9,'Vercel','https://vercel.com','','前端项目一键部署平台',3,1,'2026-04-11 10:55:57','approved',NULL,''),(55,9,'Netlify','https://www.netlify.com','','静态站点和 Serverless 部署平台',4,1,'2026-04-11 10:55:57','approved',NULL,''),(56,9,'宝塔面板','https://www.bt.cn','','Linux 服务器运维管理面板',5,1,'2026-04-11 10:55:57','approved',NULL,''),(57,9,'Nginx 文档','https://nginx.org/en/docs/','','Nginx 官方文档',6,1,'2026-04-11 10:55:57','approved',NULL,''),(58,9,'Cloudflare','https://www.cloudflare.com','','CDN、DNS 和网络安全服务',7,1,'2026-04-11 10:55:57','approved',NULL,''),(59,9,'阿里云','https://www.aliyun.com','','国内领先的云计算平台',8,1,'2026-04-11 10:55:57','approved',NULL,''),(60,9,'腾讯云','https://cloud.tencent.com','','腾讯旗下云计算服务平台',9,1,'2026-04-11 10:55:57','approved',NULL,''),(61,10,'Figma','https://www.figma.com','','在线协作 UI 设计工具',1,1,'2026-04-11 10:55:57','approved',NULL,''),(62,10,'Dribbble','https://dribbble.com','','设计师作品展示平台,找灵感必备',2,1,'2026-04-11 10:55:57','approved',NULL,''),(63,10,'Iconfont','https://www.iconfont.cn','','阿里巴巴矢量图标库',3,1,'2026-04-11 10:55:57','approved',NULL,''),(64,10,'Heroicons','https://heroicons.com','','Tailwind CSS 团队出品的 SVG 图标库',4,1,'2026-04-11 10:55:57','approved',NULL,''),(65,10,'Lucide Icons','https://lucide.dev','','开源精美图标库,Feather Icons 继任者',5,1,'2026-04-11 10:55:57','approved',NULL,''),(66,10,'Unsplash','https://unsplash.com','','免费高质量图片素材库',6,1,'2026-04-11 10:55:57','approved',NULL,''),(67,10,'Coolors','https://coolors.co','','配色方案生成器',7,1,'2026-04-11 10:55:57','approved',NULL,''),(68,10,'Shadcn/ui','https://ui.shadcn.com','','精美的 React 可复用组件集合',8,1,'2026-04-11 10:55:57','approved',NULL,''),(69,11,'Postman','https://www.postman.com','','API 测试和协作平台',1,1,'2026-04-11 10:55:57','approved',NULL,''),(70,11,'Swagger','https://swagger.io','','API 文档规范和工具集',2,1,'2026-04-11 10:55:57','approved',NULL,''),(71,11,'Hoppscotch','https://hoppscotch.io','','开源轻量 API 测试工具',3,1,'2026-04-11 10:55:57','approved',NULL,''),(72,11,'Regex101','https://regex101.com','','在线正则表达式测试和调试',4,1,'2026-04-11 10:55:57','approved',NULL,''),(73,11,'JSON Editor Online','https://jsoneditoronline.org','','在线 JSON 编辑和格式化工具',5,1,'2026-04-11 10:55:57','approved',NULL,''),(74,11,'DevDocs','https://devdocs.io','','聚合多种编程语言和框架的 API 文档',6,1,'2026-04-11 10:55:57','approved',NULL,''),(75,11,'Carbon','https://carbon.now.sh','','生成漂亮的代码截图',7,1,'2026-04-11 10:55:57','approved',NULL,''),(76,12,'菜鸟教程','https://www.runoob.com','','中文编程入门教程,覆盖多种语言',1,1,'2026-04-11 10:55:57','approved',NULL,''),(77,12,'W3Schools','https://www.w3schools.com','','Web 技术在线教程和参考手册',2,1,'2026-04-11 10:55:57','approved',NULL,''),(78,12,'freeCodeCamp','https://www.freecodecamp.org','','免费学习编程的开源平台',3,1,'2026-04-11 10:55:57','approved',NULL,''),(79,12,'LeetCode','https://leetcode.cn','','算法刷题平台,面试必备',4,1,'2026-04-11 10:55:57','approved',NULL,''),(80,12,'牛客网','https://www.nowcoder.com','','IT 面试刷题和求职平台',5,1,'2026-04-11 10:55:57','approved',NULL,''),(81,12,'Coursera','https://www.coursera.org','','全球顶尖大学在线课程平台',6,1,'2026-04-11 10:55:57','approved',NULL,''),(82,12,'极客时间','https://time.geekbang.org','','IT 技术付费学习平台,内容体系化',7,1,'2026-04-11 10:55:57','approved',NULL,''),(83,1,'Skills.sh','https://skills.sh','','一键安装 Agent Skills,自动检测多种 Agent 并安装',2,1,'2026-04-11 10:58:06','approved',NULL,''),(84,1,'SkillsMP','https://skillsmp.com','','Agent Skills 趋势分析市场,支持分类排序和增长趋势查看',3,1,'2026-04-11 10:58:06','approved',NULL,''),(85,1,'Claude Marketplaces','https://claudemarketplaces.com','','Claude Code 插件、Skills 和 MCP 服务器精选目录',4,1,'2026-04-11 10:58:06','approved',NULL,''),(86,1,'Anthropic 官方 Skills','https://github.com/anthropics/skills','','Anthropic 官方 Agent Skills 仓库',5,1,'2026-04-11 10:58:06','approved',NULL,''),(87,1,'Awesome Agent Skills (VoltAgent)','https://github.com/VoltAgent/awesome-agent-skills','','1000+ Agent Skills 精选集合,兼容 Claude Code、Codex、Gemini CLI 等',6,1,'2026-04-11 10:58:06','approved',NULL,''),(88,1,'Awesome Agent Skills (heilcheng)','https://github.com/heilcheng/awesome-agent-skills','','真实工程团队创建的实用 Agent Skills 教程和指南',7,1,'2026-04-11 10:58:06','approved',NULL,''),(89,1,'Everything Claude Code','https://github.com/affaan-m/everything-claude-code','','黑客松冠军作品,涵盖 Skills、Commands、Subagents、Hooks 等',8,1,'2026-04-11 10:58:06','approved',NULL,''),(90,1,'Superpowers','https://github.com/obra/superpowers','','热门 Coding Agent Skills 仓库,增强编程能力',9,1,'2026-04-11 10:58:06','approved',NULL,''),(91,1,'Claude Skills (232+)','https://github.com/alirezarezvani/claude-skills','','232+ Claude Code Skills,兼容 Codex、Gemini CLI、Cursor 等',10,1,'2026-04-11 10:58:06','approved',NULL,''),(92,1,'Skillmatic Awesome Skills','https://github.com/skillmatic-ai/awesome-agent-skills','','标准化 SKILL.md 模块包集合,渐进式加载',11,1,'2026-04-11 10:58:06','approved',NULL,''),(93,1,'MCP Market','https://mcpmarket.com','','MCP 服务器市场,连接 Claude 和 Cursor 到各种工具',12,1,'2026-04-11 10:58:06','approved',NULL,''),(94,1,'Awesome MCP Servers','https://mcpservers.org','','MCP 服务器精选列表,包含 Agent Skills 库',13,1,'2026-04-11 10:58:06','approved',NULL,''),(95,1,'Cursor Skills 文档','https://cursor.com/docs/skills','','Cursor 官方 Agent Skills 文档,开放标准规范',14,1,'2026-04-11 10:58:06','approved',NULL,''),(96,1,'AI Tmpl Plugins','https://www.aitmpl.com/plugins/','','Claude Code 插件市场和 Skill 集合目录',15,1,'2026-04-11 10:58:06','approved',NULL,''),(97,1,'Qoder Skills 文档','https://docs.qoder.com/zh/extensions/skills','','Qoder 官方 Skills 开发文档和教程',16,1,'2026-04-11 10:58:06','approved',NULL,''); +/*!40000 ALTER TABLE `nav_links` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `notifications` +-- + +DROP TABLE IF EXISTS `notifications`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `notifications` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `content` text COLLATE utf8mb4_unicode_ci, + `related_id` int DEFAULT NULL, + `from_user_id` int DEFAULT NULL, + `is_read` tinyint(1) DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `from_user_id` (`from_user_id`), + KEY `ix_notifications_id` (`id`), + KEY `ix_notifications_is_read` (`is_read`), + KEY `ix_notifications_user_id` (`user_id`), + CONSTRAINT `notifications_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), + CONSTRAINT `notifications_ibfk_2` FOREIGN KEY (`from_user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `notifications` +-- + +LOCK TABLES `notifications` WRITE; +/*!40000 ALTER TABLE `notifications` DISABLE KEYS */; +INSERT INTO `notifications` VALUES (1,1,'follow','test01 关注了你',3,3,1,'2026-04-11 10:59:20'),(2,1,'like','test01 赞了你的文章「联网搜索实现思路以及方案」',1,3,1,'2026-04-11 10:59:24'); +/*!40000 ALTER TABLE `notifications` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `posts` +-- + +DROP TABLE IF EXISTS `posts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `posts` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL, + `content` text COLLATE utf8mb4_unicode_ci NOT NULL, + `category` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tags` text COLLATE utf8mb4_unicode_ci, + `is_public` tinyint(1) DEFAULT NULL, + `view_count` int DEFAULT NULL, + `like_count` int DEFAULT NULL, + `collect_count` int DEFAULT NULL, + `comment_count` int DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `ix_posts_user_id` (`user_id`), + KEY `ix_posts_id` (`id`), + CONSTRAINT `posts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `posts` +-- + +LOCK TABLES `posts` WRITE; +/*!40000 ALTER TABLE `posts` DISABLE KEYS */; +INSERT INTO `posts` VALUES (1,1,'联网搜索实现思路以及方案','1、爬取信息,大模型搜索\n','','[]',1,6,2,1,1,'2026-04-11 05:54:44','2026-04-11 11:00:14'); +/*!40000 ALTER TABLE `posts` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_collects` +-- + +DROP TABLE IF EXISTS `project_collects`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_collects` ( + `id` int NOT NULL AUTO_INCREMENT, + `project_id` int NOT NULL, + `user_id` int NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_collect_user` (`project_id`,`user_id`), + KEY `user_id` (`user_id`), + KEY `ix_project_collects_project_id` (`project_id`), + KEY `ix_project_collects_id` (`id`), + CONSTRAINT `project_collects_ibfk_1` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`), + CONSTRAINT `project_collects_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_collects` +-- + +LOCK TABLES `project_collects` WRITE; +/*!40000 ALTER TABLE `project_collects` DISABLE KEYS */; +INSERT INTO `project_collects` VALUES (1,4,1,'2026-04-11 09:59:41'); +/*!40000 ALTER TABLE `project_collects` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `projects` +-- + +DROP TABLE IF EXISTS `projects`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `projects` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text COLLATE utf8mb4_unicode_ci, + `url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `homepage` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `icon` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `language` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `category` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `stars` int DEFAULT NULL, + `forks` int DEFAULT NULL, + `sort_order` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP, + `collect_count` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `ix_projects_category` (`category`), + KEY `ix_projects_id` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `projects` +-- + +LOCK TABLES `projects` WRITE; +/*!40000 ALTER TABLE `projects` DISABLE KEYS */; +INSERT INTO `projects` VALUES (1,'openclaw','Your own personal AI assistant. Any OS. Any Platform. The lobster way. 🦞 ','https://github.com/openclaw/openclaw','https://openclaw.ai','https://avatars.githubusercontent.com/u/252820863?v=4','TypeScript','其他',354373,71636,1,1,'2026-04-11 07:28:05','2026-04-11 07:28:05',0),(2,'claw-code','The repo is finally unlocked. enjoy the party! The fastest repo in history to surpass 100K stars ⭐. Join Discord: https://discord.gg/5TUQKqFWd Built in Rust using oh-my-codex.','https://github.com/ultraworkers/claw-code','','https://avatars.githubusercontent.com/u/272961631?v=4','Rust','其他',181024,107070,2,1,'2026-04-11 07:28:05','2026-04-11 07:28:05',0),(3,'everything-claude-code','The agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.','https://github.com/affaan-m/everything-claude-code','https://ecc.tools','https://avatars.githubusercontent.com/u/124439313?v=4','JavaScript','AI 相关',150482,23290,3,1,'2026-04-11 07:28:05','2026-04-11 07:28:05',0),(4,'superpowers','An agentic skills framework & software development methodology that works.','https://github.com/obra/superpowers','','https://avatars.githubusercontent.com/u/45416?v=4','Shell','其他',146169,12534,4,1,'2026-04-11 07:28:05','2026-04-11 09:59:41',1),(5,'opencode','The open source coding agent.','https://github.com/anomalyco/opencode','https://opencode.ai','https://avatars.githubusercontent.com/u/66570915?v=4','TypeScript','其他',141285,15794,5,1,'2026-04-11 07:28:05','2026-04-11 07:28:05',0),(6,'system-prompts-and-models-of-ai-tools','FULL Augment Code, Claude Code, Cluely, CodeBuddy, Comet, Cursor, Devin AI, Junie, Kiro, Leap.new, Lovable, Manus, NotionAI, Orchids.app, Perplexity, Poke, Qoder, Replit, Same.dev, Trae, Traycer AI, VSCode Agent, Warp.dev, Windsurf, Xcode, Z.ai Code, Dia & v0. (And other Open Sourced) System Prompts, Internal Tools & AI Models','https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools','','https://avatars.githubusercontent.com/u/185671340?v=4','','其他',134911,33920,6,1,'2026-04-11 07:28:05','2026-04-11 07:28:05',0); +/*!40000 ALTER TABLE `projects` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `shared_api_categories` +-- + +DROP TABLE IF EXISTS `shared_api_categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `shared_api_categories` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `icon` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sort_order` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + KEY `ix_shared_api_categories_id` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `shared_api_categories` +-- + +LOCK TABLES `shared_api_categories` WRITE; +/*!40000 ALTER TABLE `shared_api_categories` DISABLE KEYS */; +INSERT INTO `shared_api_categories` VALUES (1,'大语言模型','',1,1,'2026-04-11 08:02:16'),(2,'图像生成','',2,1,'2026-04-11 08:02:16'),(3,'语音识别/合成','',3,1,'2026-04-11 08:02:16'),(4,'向量/嵌入','',4,1,'2026-04-11 08:02:16'),(5,'搜索/检索','',5,1,'2026-04-11 08:02:16'),(6,'翻译服务','',6,1,'2026-04-11 08:02:16'),(7,'代码生成','',7,1,'2026-04-11 08:02:16'),(8,'数据分析','',8,1,'2026-04-11 08:02:16'),(9,'云服务/基础设施','',9,1,'2026-04-11 08:02:16'),(10,'支付/金融','',10,1,'2026-04-11 08:02:16'),(11,'消息推送','',11,1,'2026-04-11 08:02:16'),(12,'地图/位置','',12,1,'2026-04-11 08:02:16'),(13,'其他','',99,1,'2026-04-11 08:02:16'); +/*!40000 ALTER TABLE `shared_api_categories` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `shared_api_logs` +-- + +DROP TABLE IF EXISTS `shared_api_logs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `shared_api_logs` ( + `id` int NOT NULL AUTO_INCREMENT, + `api_id` int NOT NULL, + `user_id` int DEFAULT NULL, + `action` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `request_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `response_status` int DEFAULT NULL, + `response_time_ms` int DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + KEY `ix_shared_api_logs_api_id` (`api_id`), + KEY `ix_shared_api_logs_id` (`id`), + CONSTRAINT `shared_api_logs_ibfk_1` FOREIGN KEY (`api_id`) REFERENCES `shared_apis` (`id`), + CONSTRAINT `shared_api_logs_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `shared_api_logs` +-- + +LOCK TABLES `shared_api_logs` WRITE; +/*!40000 ALTER TABLE `shared_api_logs` DISABLE KEYS */; +/*!40000 ALTER TABLE `shared_api_logs` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `shared_apis` +-- + +DROP TABLE IF EXISTS `shared_apis`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `shared_apis` ( + `id` int NOT NULL AUTO_INCREMENT, + `category_id` int DEFAULT NULL, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text COLLATE utf8mb4_unicode_ci, + `base_url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `doc_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `auth_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `api_key_encrypted` text COLLATE utf8mb4_unicode_ci, + `api_key_header` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `health_check_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `last_check_time` datetime DEFAULT NULL, + `last_check_status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `added_by` int DEFAULT NULL, + `tags` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `call_count` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `added_by` (`added_by`), + KEY `ix_shared_apis_id` (`id`), + KEY `ix_shared_apis_category_id` (`category_id`), + CONSTRAINT `shared_apis_ibfk_1` FOREIGN KEY (`category_id`) REFERENCES `shared_api_categories` (`id`), + CONSTRAINT `shared_apis_ibfk_2` FOREIGN KEY (`added_by`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `shared_apis` +-- + +LOCK TABLES `shared_apis` WRITE; +/*!40000 ALTER TABLE `shared_apis` DISABLE KEYS */; +/*!40000 ALTER TABLE `shared_apis` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `system_configs` +-- + +DROP TABLE IF EXISTS `system_configs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `system_configs` ( + `id` int NOT NULL AUTO_INCREMENT, + `key` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `value` text COLLATE utf8mb4_unicode_ci, + `description` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `ix_system_configs_key` (`key`), + KEY `ix_system_configs_id` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `system_configs` +-- + +LOCK TABLES `system_configs` WRITE; +/*!40000 ALTER TABLE `system_configs` DISABLE KEYS */; +INSERT INTO `system_configs` VALUES (1,'api_hub_password','158a323a7ba44870f23d96f1516dd70aa48e9a72db4ebb026b0a89e212a208ab','API Hub访问密码','2026-04-11 07:59:52'),(2,'cos_secret_id','AKIDpZ5NGvLYXHn6BC5lBNJ0TlpiT2OVN0Qd','SecretId','2026-04-11 08:44:39'),(3,'cos_secret_key','BF5g17zdDbnlcO0OXSJ6nOwNhy97vTmp','SecretKey','2026-04-11 08:44:39'),(4,'cos_bucket','bianchengshequ-1258043434','Bucket(如 bianchengshequ-1250000000)','2026-04-11 08:44:39'),(5,'cos_region','ap-guangzhou','Region(如 ap-beijing)','2026-04-11 08:44:39'),(6,'cos_custom_domain','','自定义域名(可选,CDN加速域名)','2026-04-11 08:44:39'),(7,'kb_password','158a323a7ba44870f23d96f1516dd70aa48e9a72db4ebb026b0a89e212a208ab','知识库访问密码','2026-04-11 09:25:00'); +/*!40000 ALTER TABLE `system_configs` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `users` ( + `id` int NOT NULL AUTO_INCREMENT, + `username` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `password_hash` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL, + `avatar` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_admin` tinyint(1) NOT NULL, + `is_banned` tinyint(1) NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `is_approved` tinyint(1) NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_users_email` (`email`), + UNIQUE KEY `ix_users_username` (`username`), + KEY `ix_users_id` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `users` +-- + +LOCK TABLES `users` WRITE; +/*!40000 ALTER TABLE `users` DISABLE KEYS */; +INSERT INTO `users` VALUES (1,'test','test@163.com','$2b$12$ojBnEWPvF3BeDDJjZxni7eCZsi3a05/NleX7tML.PAm.3UfVWAGmG','',1,0,'2026-04-01 06:32:01',1),(2,'admin','admin@example.com','$2b$12$IkuqH6mOmwvNXw4H5VaRs.mFtr99uv/.YmvPWVVSPrSK1alUWdyzu','',1,0,'2026-04-05 02:56:03',1),(3,'test01','test01@qq.com','$2b$12$uW01C0dK94V36.9pPN4Yhu55CMPm3useDICHpwIG3wInIg7O9r9xe','',0,0,'2026-04-11 10:58:28',1); +/*!40000 ALTER TABLE `users` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Dumping events for database 'biancheng' +-- + +-- +-- Dumping routines for database 'biancheng' +-- +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-04-11 11:01:45 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..419b351 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3238 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.14.0", + "highlight.js": "^11.11.1", + "markdown-it": "^14.1.1", + "mermaid": "^11.13.0", + "pinia": "^3.0.4", + "vue": "^3.5.30", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@vitejs/plugin-vue": "^6.0.5", + "tailwindcss": "^4.2.2", + "vite": "^8.0.1" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz", + "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.31", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", + "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", + "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.31", + "@vue/compiler-dom": "3.5.31", + "@vue/compiler-ssr": "3.5.31", + "@vue/shared": "3.5.31", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", + "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz", + "integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz", + "integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz", + "integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.31", + "@vue/runtime-core": "3.5.31", + "@vue/shared": "3.5.31", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz", + "integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.31", + "@vue/shared": "3.5.31" + }, + "peerDependencies": { + "vue": "3.5.31" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz", + "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/mermaid": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", + "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.31", + "@vue/compiler-sfc": "3.5.31", + "@vue/runtime-dom": "3.5.31", + "@vue/server-renderer": "3.5.31", + "@vue/shared": "3.5.31" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c38be5c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.14.0", + "highlight.js": "^11.11.1", + "markdown-it": "^14.1.1", + "mermaid": "^11.13.0", + "pinia": "^3.0.4", + "vue": "^3.5.30", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@vitejs/plugin-vue": "^6.0.5", + "tailwindcss": "^4.2.2", + "vite": "^8.0.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..f40be3f --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,30 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + timeout: 60000, +}) + +// 请求拦截器:自动添加Token +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// 响应拦截器:处理401 +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401 && !error.config.url?.includes('/auth/')) { + localStorage.removeItem('token') + localStorage.removeItem('user') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +export default api diff --git a/frontend/src/api/modules.js b/frontend/src/api/modules.js new file mode 100644 index 0000000..5726852 --- /dev/null +++ b/frontend/src/api/modules.js @@ -0,0 +1,449 @@ +import api from './index' + +export const authApi = { + register(data) { + return api.post('/auth/register', data) + }, + login(data) { + return api.post('/auth/login', data) + }, + getMe() { + return api.get('/auth/me') + }, + updateProfile(data) { + return api.put('/auth/profile', data) + }, +} + +export const requirementApi = { + getConversations() { + return api.get('/requirement/conversations') + }, + getConversation(id) { + return api.get(`/requirement/conversations/${id}`) + }, + deleteConversation(id) { + return api.delete(`/requirement/conversations/${id}`) + }, + uploadImage(file) { + const formData = new FormData() + formData.append('file', file) + return api.post('/requirement/upload-image', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + // analyze使用SSE流式,不走axios +} + +export const architectureApi = { + getConversations() { + return api.get('/architecture/conversations') + }, + getConversation(id) { + return api.get(`/architecture/conversations/${id}`) + }, + deleteConversation(id) { + return api.delete(`/architecture/conversations/${id}`) + }, +} + +export const postsApi = { + getPosts(params) { + return api.get('/posts', { params }) + }, + getPost(id) { + return api.get(`/posts/${id}`) + }, + createPost(data) { + return api.post('/posts', data) + }, + updatePost(id, data) { + return api.put(`/posts/${id}`, data) + }, + deletePost(id) { + return api.delete(`/posts/${id}`) + }, + toggleLike(id) { + return api.post(`/posts/${id}/like`) + }, + toggleCollect(id) { + return api.post(`/posts/${id}/collect`) + }, + getComments(id) { + return api.get(`/posts/${id}/comments`) + }, + createComment(id, data) { + return api.post(`/posts/${id}/comments`, data) + }, + getAttachments(id) { + return api.get(`/posts/${id}/attachments`) + }, + deleteAttachment(postId, attachmentId) { + return api.delete(`/posts/${postId}/attachments/${attachmentId}`) + }, + getDrafts(params) { + return api.get('/posts/drafts', { params }) + }, +} + +export const searchApi = { + search(params) { + return api.get('/search', { params }) + }, +} + +export const aiModelsApi = { + getPresets() { + return api.get('/admin/models/presets') + }, + getTaskTypes() { + return api.get('/admin/models/task-types') + }, + getModels(params) { + return api.get('/admin/models', { params }) + }, + createModel(data) { + return api.post('/admin/models', data) + }, + updateModel(id, data) { + return api.put(`/admin/models/${id}`, data) + }, + deleteModel(id) { + return api.delete(`/admin/models/${id}`) + }, + initDefaults() { + return api.post('/admin/models/init-defaults') + }, + testConnection(id) { + return api.post(`/admin/models/${id}/test`) + }, + // 公开接口 - 获取可用模型列表(登录用户) + getAvailableModels(params) { + return api.get('/models/available', { params }) + }, +} + +export const bookmarksApi = { + getBookmarks() { + return api.get('/bookmarks') + }, + createBookmark(data) { + return api.post('/bookmarks', data) + }, + updateBookmark(id, data) { + return api.put(`/bookmarks/${id}`, data) + }, + deleteBookmark(id) { + return api.delete(`/bookmarks/${id}`) + }, + reorder(items) { + return api.put('/bookmarks/reorder', { items }) + }, +} + +export const usersApi = { + getProfile(id) { + return api.get(`/users/${id}`) + }, + toggleFollow(id) { + return api.post(`/users/${id}/follow`) + }, + getUserPosts(id, params) { + return api.get(`/users/${id}/posts`, { params }) + }, + getUserCollects(id, params) { + return api.get(`/users/${id}/collects`, { params }) + }, + getFollowers(id, params) { + return api.get(`/users/${id}/followers`, { params }) + }, + getFollowing(id, params) { + return api.get(`/users/${id}/following`, { params }) + }, +} + +export const notificationsApi = { + getNotifications(params) { + return api.get('/notifications', { params }) + }, + getUnreadCount() { + return api.get('/notifications/unread-count') + }, + readAll() { + return api.put('/notifications/read-all') + }, + readOne(id) { + return api.put(`/notifications/${id}/read`) + }, +} + +export const feedApi = { + getFeed(params) { + return api.get('/posts/feed', { params }) + }, + getHot(params) { + return api.get('/posts/hot', { params }) + }, + getLatest(params) { + return api.get('/posts/latest', { params }) + }, +} + +export const uploadApi = { + uploadImage(file) { + const formData = new FormData() + formData.append('file', file) + return api.post('/upload/image', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + uploadAttachment(file, postId) { + const formData = new FormData() + formData.append('file', file) + if (postId) formData.append('post_id', postId) + return api.post('/upload/attachment', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + updateAttachmentPost(attachmentId, postId) { + return api.put(`/upload/attachment/${attachmentId}/post`, null, { params: { post_id: postId } }) + }, +} + +export const adminApi = { + getStats() { + return api.get('/admin/stats') + }, + getUsers(params) { + return api.get('/admin/users', { params }) + }, + toggleAdmin(id) { + return api.put(`/admin/users/${id}/toggle-admin`) + }, + toggleBan(id) { + return api.put(`/admin/users/${id}/toggle-ban`) + }, + approveUser(id) { + return api.put(`/admin/users/${id}/approve`) + }, + rejectUser(id) { + return api.put(`/admin/users/${id}/reject`) + }, + getPosts(params) { + return api.get('/admin/posts', { params }) + }, + deletePost(id) { + return api.delete(`/admin/posts/${id}`) + }, + // 对象存储管理 + getStorageConfig() { + return api.get('/admin/storage/config') + }, + updateStorageConfig(data) { + return api.put('/admin/storage/config', data) + }, + testStorageConnection() { + return api.post('/admin/storage/test') + }, + // 分类管理 + getCategories() { + return api.get('/admin/categories') + }, + createCategory(data) { + return api.post('/admin/categories', data) + }, + updateCategory(id, data) { + return api.put(`/admin/categories/${id}`, data) + }, + deleteCategory(id) { + return api.delete(`/admin/categories/${id}`) + }, +} + +// 公开分类API +export const categoryApi = { + getActiveCategories() { + return api.get('/admin/public/categories') + }, +} + +// 开源项目API +export const projectsApi = { + // 管理员接口 + adminList(params) { + return api.get('/projects/admin/list', { params }) + }, + adminCreate(data) { + return api.post('/projects/admin', data) + }, + adminUpdate(id, data) { + return api.put(`/projects/admin/${id}`, data) + }, + adminDelete(id) { + return api.delete(`/projects/admin/${id}`) + }, + githubSearch(params) { + return api.get('/projects/admin/github-search', { params }) + }, + githubImport(data) { + return api.post('/projects/admin/github-import', data) + }, + // 公开接口 + getHot(params) { + return api.get('/projects/hot', { params }) + }, + getLatest(params) { + return api.get('/projects/latest', { params }) + }, + search(params) { + return api.get('/projects/search', { params }) + }, + getCategories() { + return api.get('/projects/categories') + }, + getDetail(id) { + return api.get(`/projects/${id}`) + }, + // 公开GitHub搜索(登录用户可用) + publicGithubSearch(params) { + return api.get('/projects/github-search', { params }) + }, + // 收藏 + toggleCollect(id) { + return api.post(`/projects/${id}/collect`) + }, + getMyCollects(params) { + return api.get('/projects/my-collects', { params }) + }, +} + +// 导航站API +export const navApi = { + // 管理员接口 + getCategories() { + return api.get('/nav/admin/categories') + }, + createCategory(data) { + return api.post('/nav/admin/categories', data) + }, + updateCategory(id, data) { + return api.put(`/nav/admin/categories/${id}`, data) + }, + deleteCategory(id) { + return api.delete(`/nav/admin/categories/${id}`) + }, + getLinks(params) { + return api.get('/nav/admin/links', { params }) + }, + createLink(data) { + return api.post('/nav/admin/links', data) + }, + updateLink(id, data) { + return api.put(`/nav/admin/links/${id}`, data) + }, + deleteLink(id) { + return api.delete(`/nav/admin/links/${id}`) + }, + // 公开接口 + getPublicNav() { + return api.get('/nav/public') + }, + getPublicCategories() { + return api.get('/nav/public/categories') + }, + // 用户提交 + submitLink(data) { + return api.post('/nav/submit', data) + }, + getMySubmissions() { + return api.get('/nav/my-submissions') + }, + // 审核 + getPendingCount() { + return api.get('/nav/admin/pending-count') + }, + reviewLink(id, data) { + return api.put(`/nav/admin/links/${id}/review`, data) + }, +} + +// API Hub +function hubHeaders() { + const t = sessionStorage.getItem('hub_token') + return t ? { 'X-Hub-Token': t } : {} +} + +export const apiHubApi = { + // 密码认证 + auth(password) { return api.post('/api-hub/auth', { password }) }, + checkPassword() { return api.get('/api-hub/check-password') }, + // 管理员设置密码 + setPassword(password) { return api.put('/api-hub/admin/password', { password }) }, + getPasswordStatus() { return api.get('/api-hub/admin/password-status') }, + // 分类 + getCategories() { return api.get('/api-hub/categories', { headers: hubHeaders() }) }, + createCategory(data) { return api.post('/api-hub/categories', data, { headers: hubHeaders() }) }, + updateCategory(id, data) { return api.put(`/api-hub/categories/${id}`, data, { headers: hubHeaders() }) }, + deleteCategory(id) { return api.delete(`/api-hub/categories/${id}`, { headers: hubHeaders() }) }, + // API CRUD + getApis(params) { return api.get('/api-hub/list', { params, headers: hubHeaders() }) }, + createApi(data) { return api.post('/api-hub/', data, { headers: hubHeaders() }) }, + updateApi(id, data) { return api.put(`/api-hub/${id}`, data, { headers: hubHeaders() }) }, + deleteApi(id) { return api.delete(`/api-hub/${id}`, { headers: hubHeaders() }) }, + // 测试和健康检查 + testApi(id, data) { return api.post(`/api-hub/${id}/test`, data, { headers: hubHeaders() }) }, + healthCheck(id) { return api.post(`/api-hub/${id}/health-check`, {}, { headers: hubHeaders() }) }, + // 日志和统计 + getLogs(id, params) { return api.get(`/api-hub/${id}/logs`, { params, headers: hubHeaders() }) }, + getStats() { return api.get('/api-hub/stats', { headers: hubHeaders() }) }, +} + +// 团队知识库 +function kbHeaders() { + const t = sessionStorage.getItem('kb_token') + return t ? { 'X-Kb-Token': t } : {} +} + +export const kbApi = { + // 密码认证 + auth(password) { return api.post('/kb/auth', { password }) }, + checkPassword() { return api.get('/kb/check-password') }, + // 公开接口 + getCategories() { return api.get('/kb/categories', { headers: kbHeaders() }) }, + getItems(params) { return api.get('/kb/items', { params, headers: kbHeaders() }) }, + getItem(id) { return api.get(`/kb/items/${id}`, { headers: kbHeaders() }) }, + getStats() { return api.get('/kb/stats', { headers: kbHeaders() }) }, + // 管理员接口 + setPassword(password) { return api.put('/kb/admin/password', { password }) }, + getPasswordStatus() { return api.get('/kb/admin/password-status') }, + adminGetCategories() { return api.get('/kb/admin/categories') }, + adminCreateCategory(data) { return api.post('/kb/admin/categories', data) }, + adminUpdateCategory(id, data) { return api.put(`/kb/admin/categories/${id}`, data) }, + adminDeleteCategory(id) { return api.delete(`/kb/admin/categories/${id}`) }, + adminGetItems(params) { return api.get('/kb/admin/items', { params }) }, + adminAddItems(data) { return api.post('/kb/admin/items', data) }, + adminUpdateItem(id, data) { return api.put(`/kb/admin/items/${id}`, data) }, + adminDeleteItem(id) { return api.delete(`/kb/admin/items/${id}`) }, + adminGetPostsForPick(params) { return api.get('/kb/admin/posts-for-pick', { params }) }, + adminGetStats() { return api.get('/kb/admin/stats') }, +} + +// 联网搜索 +export const webSearchApi = { + getConversations() { + return api.get('/web-search/conversations') + }, + getConversation(id) { + return api.get(`/web-search/conversations/${id}`) + }, + deleteConversation(id) { + return api.delete(`/web-search/conversations/${id}`) + }, + // search 使用 SSE 不走 axios +} + +export const aiFormatApi = { + formatArticle(data) { + return api.post('/ai/format', data, { timeout: 120000 }) + }, +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..c4b3a5f --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import './style.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..a1d15d3 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,198 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/Login.vue'), + meta: { guest: true }, + }, + { + path: '/', + component: () => import('../views/Layout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Home', + component: () => import('../views/Home.vue'), + }, + { + path: 'discover', + redirect: '/', + }, + { + path: 'tools', + name: 'ToolHub', + component: () => import('../views/ToolHub.vue'), + }, + { + path: 'tools/requirement', + name: 'Requirement', + component: () => import('../views/RequirementAssistant.vue'), + }, + { + path: 'tools/architecture', + name: 'Architecture', + component: () => import('../views/ArchitectureAssistant.vue'), + }, + { + path: 'tools/api-hub', + name: 'ApiHub', + component: () => import('../views/ApiHub.vue'), + }, + { + path: 'tools/web-search', + name: 'WebSearch', + component: () => import('../views/WebSearch.vue'), + }, + { + path: 'post/new', + name: 'PostEditor', + component: () => import('../views/PostEditor.vue'), + }, + { + path: 'post/edit/:id', + name: 'PostEdit', + component: () => import('../views/PostEditor.vue'), + }, + { + path: 'drafts', + name: 'Drafts', + component: () => import('../views/Drafts.vue'), + }, + { + path: 'post/:id', + name: 'PostDetail', + component: () => import('../views/PostDetail.vue'), + }, + { + path: 'notifications', + name: 'Notifications', + component: () => import('../views/Notifications.vue'), + }, + { + path: 'user/:id', + name: 'UserProfile', + component: () => import('../views/UserProfile.vue'), + }, + { + path: 'profile', + name: 'Profile', + component: () => import('../views/Profile.vue'), + }, + { + path: 'browser', + name: 'Browser', + component: () => import('../views/BrowserPage.vue'), + }, + { + path: 'nav', + name: 'Navigation', + component: () => import('../views/Navigation.vue'), + }, + { + path: 'projects', + name: 'Projects', + component: () => import('../views/Projects.vue'), + }, + { + path: 'kb', + name: 'KnowledgeBase', + component: () => import('../views/KnowledgeBase.vue'), + }, + ], + }, + { + path: '/admin', + component: () => import('../views/admin/AdminLayout.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, + children: [ + { + path: '', + name: 'AdminDashboard', + component: () => import('../views/admin/AdminDashboard.vue'), + }, + { + path: 'users', + name: 'AdminUsers', + component: () => import('../views/admin/AdminUsers.vue'), + }, + { + path: 'posts', + name: 'AdminPosts', + component: () => import('../views/admin/AdminPosts.vue'), + }, + { + path: 'models', + name: 'AdminModels', + component: () => import('../views/ModelManagement.vue'), + }, + { + path: 'storage', + name: 'AdminStorage', + component: () => import('../views/admin/AdminStorage.vue'), + }, + { + path: 'categories', + name: 'AdminCategories', + component: () => import('../views/admin/AdminCategories.vue'), + }, + { + path: 'nav', + name: 'AdminNav', + component: () => import('../views/admin/AdminNav.vue'), + }, + { + path: 'projects', + name: 'AdminProjects', + component: () => import('../views/admin/AdminProjects.vue'), + }, + { + path: 'api-hub', + name: 'AdminApiHub', + component: () => import('../views/admin/AdminApiHub.vue'), + }, + { + path: 'kb', + name: 'AdminKnowledgeBase', + component: () => import('../views/admin/AdminKnowledgeBase.vue'), + }, + ], + }, + // 未匹配路由重定向到首页 + { + path: '/:pathMatch(.*)*', + redirect: '/', + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('token') + const requiresAuth = to.matched.some(record => record.meta.requiresAuth) + const isGuest = to.matched.some(record => record.meta.guest) + const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin) + + if (requiresAuth && !token) { + next('/login') + } else if (isGuest && token) { + next('/') + } else if (requiresAdmin) { + const user = JSON.parse(localStorage.getItem('user') || 'null') + if (!user?.is_admin) { + next('/') + } else { + next() + } + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/tabs.js b/frontend/src/stores/tabs.js new file mode 100644 index 0000000..3671768 --- /dev/null +++ b/frontend/src/stores/tabs.js @@ -0,0 +1,98 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useTabsStore = defineStore('tabs', () => { + // 固定标签页(不可关闭) + const fixedTabs = [ + { id: 'requirement', type: 'route', title: '需求助手', path: '/requirement', closable: false }, + { id: 'architecture', type: 'route', title: '架构助手', path: '/architecture', closable: false }, + { id: 'knowledge', type: 'route', title: '经验知识库', path: '/knowledge', closable: false }, + ] + + // 动态标签页(可关闭的,包括浏览器标签和管理页面) + const dynamicTabs = ref([]) + + // 当前激活的标签 ID + const activeTabId = ref('requirement') + + // 所有标签 + const allTabs = computed(() => [...fixedTabs, ...dynamicTabs.value]) + + // 当前激活的标签 + const activeTab = computed(() => allTabs.value.find(t => t.id === activeTabId.value)) + + // 浏览器标签(用于 v-show 保持 iframe 实例) + const browserTabs = computed(() => dynamicTabs.value.filter(t => t.type === 'browser')) + + // 最大 iframe 数量 + const MAX_BROWSER_TABS = 8 + + function setActiveTab(tabId) { + activeTabId.value = tabId + } + + function openRouteTab(id, title, path) { + // 如果已存在,直接切换 + const existing = allTabs.value.find(t => t.id === id) + if (existing) { + activeTabId.value = id + return + } + dynamicTabs.value.push({ id, type: 'route', title, path, closable: true }) + activeTabId.value = id + } + + function openBrowserTab(name, url) { + // 检查是否已打开同 URL 的标签 + const existing = dynamicTabs.value.find(t => t.type === 'browser' && t.url === url) + if (existing) { + activeTabId.value = existing.id + return + } + + // 检查数量限制 + if (browserTabs.value.length >= MAX_BROWSER_TABS) { + return { error: `最多同时打开 ${MAX_BROWSER_TABS} 个网站标签,请先关闭一些` } + } + + const id = `browser_${Date.now()}` + dynamicTabs.value.push({ id, type: 'browser', title: name, url, closable: true }) + activeTabId.value = id + return { id } + } + + function closeTab(tabId) { + const idx = dynamicTabs.value.findIndex(t => t.id === tabId) + if (idx === -1) return + + dynamicTabs.value.splice(idx, 1) + + // 如果关闭的是当前标签,切换到前一个或第一个 + if (activeTabId.value === tabId) { + if (dynamicTabs.value.length > 0 && idx > 0) { + activeTabId.value = dynamicTabs.value[Math.min(idx - 1, dynamicTabs.value.length - 1)].id + } else { + activeTabId.value = fixedTabs[0].id + } + } + } + + function switchToRequirement(prefillText) { + activeTabId.value = 'requirement' + return prefillText + } + + return { + fixedTabs, + dynamicTabs, + activeTabId, + allTabs, + activeTab, + browserTabs, + setActiveTab, + openRouteTab, + openBrowserTab, + closeTab, + switchToRequirement, + } +}) diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js new file mode 100644 index 0000000..4c861ae --- /dev/null +++ b/frontend/src/stores/user.js @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { authApi } from '../api/modules' + +export const useUserStore = defineStore('user', () => { + const token = ref(localStorage.getItem('token') || '') + const user = ref(JSON.parse(localStorage.getItem('user') || 'null')) + const isLoggedIn = ref(!!token.value) + + async function login(username, password) { + const res = await authApi.login({ username, password }) + token.value = res.data.access_token + user.value = res.data.user + isLoggedIn.value = true + localStorage.setItem('token', token.value) + localStorage.setItem('user', JSON.stringify(user.value)) + } + + async function register(username, email, password) { + const res = await authApi.register({ username, email, password }) + token.value = res.data.access_token + user.value = res.data.user + isLoggedIn.value = true + localStorage.setItem('token', token.value) + localStorage.setItem('user', JSON.stringify(user.value)) + } + + function logout() { + token.value = '' + user.value = null + isLoggedIn.value = false + localStorage.removeItem('token') + localStorage.removeItem('user') + } + + function updateUser(newUser) { + user.value = newUser + localStorage.setItem('user', JSON.stringify(newUser)) + } + + return { token, user, isLoggedIn, login, register, logout, updateUser } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..c4eeaeb --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,84 @@ +@import "tailwindcss"; + +/* 自定义基础样式 */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +/* Markdown渲染样式 */ +.markdown-body h1 { font-size: 1.5em; font-weight: 700; margin: 1em 0 0.5em; } +.markdown-body h2 { font-size: 1.3em; font-weight: 600; margin: 1em 0 0.5em; } +.markdown-body h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.4em; } +.markdown-body p { margin: 1em 0; line-height: 1.8; } +.markdown-body ul, .markdown-body ol { padding-left: 1.5em; margin: 0.5em 0; } +.markdown-body li { margin: 0.3em 0; } +.markdown-body code { + background: rgba(127, 127, 127, 0.15); + padding: 0.15em 0.4em; + border-radius: 4px; + font-size: 0.9em; +} +.markdown-body pre { + background: #1e1e2e; + color: #cdd6f4; + padding: 1em; + border-radius: 8px; + overflow-x: auto; + margin: 0.8em 0; +} +.markdown-body pre code { + background: none; + padding: 0; +} +.markdown-body table { + border-collapse: collapse; + width: 100%; + margin: 0.8em 0; +} +.markdown-body th, .markdown-body td { + border: 1px solid rgba(127, 127, 127, 0.3); + padding: 0.5em 0.8em; + text-align: left; +} +.markdown-body th { + background: rgba(127, 127, 127, 0.1); + font-weight: 600; +} +.markdown-body blockquote { + border-left: 3px solid #6366f1; + padding-left: 1em; + margin: 0.5em 0; + color: rgba(127, 127, 127, 0.8); +} + +/* 思考过程折叠样式 */ +.markdown-body details { + background: rgba(99, 102, 241, 0.08); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 8px; + padding: 0; + margin: 0.5em 0 1em; + overflow: hidden; +} +.markdown-body details summary { + padding: 0.6em 1em; + cursor: pointer; + font-weight: 500; + color: #a5b4fc; + user-select: none; + font-size: 0.85em; +} +.markdown-body details summary:hover { + background: rgba(99, 102, 241, 0.1); +} +.markdown-body details[open] summary { + border-bottom: 1px solid rgba(99, 102, 241, 0.15); + margin-bottom: 0.5em; +} +.markdown-body details > *:not(summary) { + padding: 0 1em; + font-size: 0.85em; + color: #9ca3af; + line-height: 1.6; +} diff --git a/frontend/src/views/ApiHub.vue b/frontend/src/views/ApiHub.vue new file mode 100644 index 0000000..bd6557a --- /dev/null +++ b/frontend/src/views/ApiHub.vue @@ -0,0 +1,512 @@ + + + diff --git a/frontend/src/views/ArchitectureAssistant.vue b/frontend/src/views/ArchitectureAssistant.vue new file mode 100644 index 0000000..ef7a32a --- /dev/null +++ b/frontend/src/views/ArchitectureAssistant.vue @@ -0,0 +1,261 @@ + + + diff --git a/frontend/src/views/BookmarkManager.vue b/frontend/src/views/BookmarkManager.vue new file mode 100644 index 0000000..aa48c6e --- /dev/null +++ b/frontend/src/views/BookmarkManager.vue @@ -0,0 +1,140 @@ + + + diff --git a/frontend/src/views/BrowserPage.vue b/frontend/src/views/BrowserPage.vue new file mode 100644 index 0000000..01d150c --- /dev/null +++ b/frontend/src/views/BrowserPage.vue @@ -0,0 +1,165 @@ + + + diff --git a/frontend/src/views/BrowserSidebar.vue b/frontend/src/views/BrowserSidebar.vue new file mode 100644 index 0000000..7e9a041 --- /dev/null +++ b/frontend/src/views/BrowserSidebar.vue @@ -0,0 +1,143 @@ + + + diff --git a/frontend/src/views/BrowserView.vue b/frontend/src/views/BrowserView.vue new file mode 100644 index 0000000..08ee43a --- /dev/null +++ b/frontend/src/views/BrowserView.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/views/Drafts.vue b/frontend/src/views/Drafts.vue new file mode 100644 index 0000000..9ac66ec --- /dev/null +++ b/frontend/src/views/Drafts.vue @@ -0,0 +1,140 @@ + + + diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..18ff3b3 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,332 @@ + + + diff --git a/frontend/src/views/KnowledgeBase.vue b/frontend/src/views/KnowledgeBase.vue new file mode 100644 index 0000000..e820290 --- /dev/null +++ b/frontend/src/views/KnowledgeBase.vue @@ -0,0 +1,391 @@ + + + diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue new file mode 100644 index 0000000..c5053b4 --- /dev/null +++ b/frontend/src/views/Layout.vue @@ -0,0 +1,183 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..1539250 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/src/views/ModelManagement.vue b/frontend/src/views/ModelManagement.vue new file mode 100644 index 0000000..d8c8a6e --- /dev/null +++ b/frontend/src/views/ModelManagement.vue @@ -0,0 +1,348 @@ + + + diff --git a/frontend/src/views/Navigation.vue b/frontend/src/views/Navigation.vue new file mode 100644 index 0000000..39e012b --- /dev/null +++ b/frontend/src/views/Navigation.vue @@ -0,0 +1,261 @@ + + + diff --git a/frontend/src/views/Notifications.vue b/frontend/src/views/Notifications.vue new file mode 100644 index 0000000..5336bbb --- /dev/null +++ b/frontend/src/views/Notifications.vue @@ -0,0 +1,203 @@ + + + diff --git a/frontend/src/views/PostDetail.vue b/frontend/src/views/PostDetail.vue new file mode 100644 index 0000000..410ddd7 --- /dev/null +++ b/frontend/src/views/PostDetail.vue @@ -0,0 +1,232 @@ + + + diff --git a/frontend/src/views/PostEditor.vue b/frontend/src/views/PostEditor.vue new file mode 100644 index 0000000..52b6ae8 --- /dev/null +++ b/frontend/src/views/PostEditor.vue @@ -0,0 +1,947 @@ + + + diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..2c43a0e --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,416 @@ + + + diff --git a/frontend/src/views/Projects.vue b/frontend/src/views/Projects.vue new file mode 100644 index 0000000..0df4014 --- /dev/null +++ b/frontend/src/views/Projects.vue @@ -0,0 +1,385 @@ + + + diff --git a/frontend/src/views/RequirementAssistant.vue b/frontend/src/views/RequirementAssistant.vue new file mode 100644 index 0000000..998d659 --- /dev/null +++ b/frontend/src/views/RequirementAssistant.vue @@ -0,0 +1,376 @@ + + + diff --git a/frontend/src/views/ToolHub.vue b/frontend/src/views/ToolHub.vue new file mode 100644 index 0000000..f372656 --- /dev/null +++ b/frontend/src/views/ToolHub.vue @@ -0,0 +1,75 @@ + diff --git a/frontend/src/views/UserProfile.vue b/frontend/src/views/UserProfile.vue new file mode 100644 index 0000000..5a1106b --- /dev/null +++ b/frontend/src/views/UserProfile.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/src/views/WebSearch.vue b/frontend/src/views/WebSearch.vue new file mode 100644 index 0000000..3475614 --- /dev/null +++ b/frontend/src/views/WebSearch.vue @@ -0,0 +1,330 @@ + + + diff --git a/frontend/src/views/admin/AdminApiHub.vue b/frontend/src/views/admin/AdminApiHub.vue new file mode 100644 index 0000000..a66b233 --- /dev/null +++ b/frontend/src/views/admin/AdminApiHub.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/src/views/admin/AdminCategories.vue b/frontend/src/views/admin/AdminCategories.vue new file mode 100644 index 0000000..a47fb58 --- /dev/null +++ b/frontend/src/views/admin/AdminCategories.vue @@ -0,0 +1,225 @@ + + + diff --git a/frontend/src/views/admin/AdminDashboard.vue b/frontend/src/views/admin/AdminDashboard.vue new file mode 100644 index 0000000..b4b1da6 --- /dev/null +++ b/frontend/src/views/admin/AdminDashboard.vue @@ -0,0 +1,166 @@ + + + diff --git a/frontend/src/views/admin/AdminKnowledgeBase.vue b/frontend/src/views/admin/AdminKnowledgeBase.vue new file mode 100644 index 0000000..2f704aa --- /dev/null +++ b/frontend/src/views/admin/AdminKnowledgeBase.vue @@ -0,0 +1,430 @@ + + + diff --git a/frontend/src/views/admin/AdminLayout.vue b/frontend/src/views/admin/AdminLayout.vue new file mode 100644 index 0000000..3bd119f --- /dev/null +++ b/frontend/src/views/admin/AdminLayout.vue @@ -0,0 +1,104 @@ + + + diff --git a/frontend/src/views/admin/AdminNav.vue b/frontend/src/views/admin/AdminNav.vue new file mode 100644 index 0000000..232042b --- /dev/null +++ b/frontend/src/views/admin/AdminNav.vue @@ -0,0 +1,450 @@ + + + diff --git a/frontend/src/views/admin/AdminPosts.vue b/frontend/src/views/admin/AdminPosts.vue new file mode 100644 index 0000000..08b8c1f --- /dev/null +++ b/frontend/src/views/admin/AdminPosts.vue @@ -0,0 +1,137 @@ + + + diff --git a/frontend/src/views/admin/AdminProjects.vue b/frontend/src/views/admin/AdminProjects.vue new file mode 100644 index 0000000..d46ca02 --- /dev/null +++ b/frontend/src/views/admin/AdminProjects.vue @@ -0,0 +1,532 @@ + + + diff --git a/frontend/src/views/admin/AdminStorage.vue b/frontend/src/views/admin/AdminStorage.vue new file mode 100644 index 0000000..cfa9347 --- /dev/null +++ b/frontend/src/views/admin/AdminStorage.vue @@ -0,0 +1,199 @@ + + + diff --git a/frontend/src/views/admin/AdminUsers.vue b/frontend/src/views/admin/AdminUsers.vue new file mode 100644 index 0000000..0df36bd --- /dev/null +++ b/frontend/src/views/admin/AdminUsers.vue @@ -0,0 +1,216 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..4d4b9f5 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + vue(), + tailwindcss(), + ], + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + '/uploads': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + }, + }, +})