commit afac18e84312b4e5cd38c640bbc65aa3fcf9429b Author: Sisyphus Date: Wed Jun 24 11:07:11 2026 +0800 feat(F002): W26 + W29.x + W30 + W31 平台助手全部改动首次 commit - W26: NestJS API + Next.js Web + Prisma + SQLite scaffold - W26: Lead 模型 + Crawler + 询价发布助手 (线索池 + 一键发布) - W29.x: MCP 集成 (Integration 模型 + connectMcp + publish_rfq 5 工具端到端跑通) - W30: 3 字段补充 (notes / quotationType / quotationDemand) - W31 P1: 自动填 enterpriseId / userId (Prisma companyUserId 字段 + get_app_identity tool + lead.service.ts publishArgs) - W31: detail 详情页 + admin UI 集成管理 (OpenAppListView + AuditLogListView) 关联: ~/Documents/02-平台开发/产品开发/F002-W31-交接文档.md GIT-AUDIT-REPORT-2026-06-24.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..654ae82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +.next/ +out/ +build/ + +# Local data +*.db +*.db-journal +prisma/dev.db +prisma/prisma/dev.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Dev processes +.dev-api.pid +.dev-web.pid + +# Environment +.env +.env.local +.env.*.local +!.env.example + +# IDE +.vscode/ +.idea/ +*.swp +.DS_Store + +# OS +Thumbs.db + +# Backup files (防误提交) +*.bak +*.tmp +*.orig +*.rej diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c03d068 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..864f31a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,229 @@ +# AGENTS.md + +语言:中文。所有对话、注释、文档、提交信息均使用中文。 + +## 项目结构 + +``` +/ +├── apps/ +│ ├── web/ # Next.js 14 App Router + Tailwind CSS 3 +│ └── api/ # NestJS 10 + Prisma ORM + SQLite +├── packages/ +│ └── shared/ # 共享 TS 类型(编译型,无运行时依赖) +└── scripts/ + ├── parse_products.py # 解析 Excel/CSV → JSON + ├── tender-fetcher/ # 招标公告抓取脚本 + │ ├── fetch-v1.2.js # 中电建 + │ ├── fetch-xkgroup.py # 新矿集团 + │ ├── fetch-cnncecp.py # 中核集团 + │ └── lead_evaluation_report.py + └── direv-publish/ # direv 平台自动发布脚本(10 个 .py) +``` + +## 开发命令 + +```bash +npm run setup # 首次:npm install + db:init +npm run dev # 同时启动前端(3001)和后端(4000) +npm run dev:api # 仅后端(nest start --watch,用 dist/ 编译产物) +npm run dev:web # 仅前端 +npm run db:init # prisma generate → migrate → seed +npm run db:reset # 删 db 文件后 db:init +npm run build # nest build + next build +npm run lint # eslint + next lint +npm run typecheck # tsc --noEmit 所有包(无 root tsconfig,各包独立) +npm run dev:daemon # nohup 后台启动(PID 写入 .dev-api.pid / .dev-web.pid) +npm run dev:stop # kill PID 文件中记录的进程 +``` + +## 端口速查 (本地开发) + +| 端口 | 服务 | 启动方式 | +|---|---|---| +| **3001** | 平台助手 web (Next.js) | `npm run dev:web` 或 `npm run dev` | +| **4000** | 平台助手 API (NestJS) | `npm run dev:api` 或 `npm run dev` | +| **3002** | devbox MCP server (Node.js) | `ssh devbox` + PM2, **不在 Mac 本地** | + +注意:**3002 是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。 +打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002)。 + +AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。 + +## 后端陷阱 + +- **`nest start --watch` 用 `dist/` 编译产物**:Prisma schema 变更后必须 `npm run build -w apps/api` 重建 dist,再重启。单纯 `prisma generate` 不够 +- **端口 4000 残留进程**:`dev:stop` 只杀 PID 文件中记录的进程。如果有多余进程占端口,需手动 `lsof -ti:4000 | xargs kill -9` +- **Prisma 变更流程**:改 schema → `npm run db:init`(含 generate + migrate + seed) +- SQLite:`apps/api/prisma/dev.db`;种子数据(seed.ts):15 电缆型号 × 5 供应商 = 75 Product + 管理用户;`crawlerConfig` 保留 3 条(中电建 + 新矿集团 + 中核集团,中核默认 paused) +- `CrawlerService`(新增):`bid_powerchina` 类型 → `execSync('node fetch-v1.2.js')`;`bid_xkgroup` 类型 → `execSync('python3 fetch-xkgroup.py')`。脚本路径硬编码为 `scripts/tender-fetcher/`,结果从 `~/Desktop/招标公告//downloaded.json` 读取,upsert 到 Lead 表 +- Lead 表 `noticeId` 从 `summary` 中提取时曾因 `substr` 字符/字节偏移混淆导致截断错误(`+8` 应为 `+6`)。提取公告 ID 统一用 `substr(summary, instr(summary, '公告ID:') + 6, 32)` +- Lead 表 `@@unique([source, noticeId])`:去重键,`CrawlerService.run()` 用 `upsert` 写入,重复则更新 summary/detailUrl +- Lead 表 `detailUrl`:独立字段,前端显示为可点击链接。脚本保存 `id` 后重构 URL(中电建用 `r.id` 而非 `systemId`;新矿需补 `&userId=guest&accountId=2066845847806639104` 认证参数) +- 后端无测试框架 + +## 前端陷阱 + +- **SPA 无路由**:`page.tsx` 用 `useState` 切换 14 个组件(含 LoginPage + Sidebar + Providers)。导航通过 `onNavigate` prop 传递 `page` 值,参数通过 props +- **API base**:`src/lib/apiBase.ts` 暴露 `API_BASE`,构建期由 `NEXT_PUBLIC_API_URL` 注入。本地开发不设环境变量时回退到 `http://localhost:4000`,生产构建为 `/api`(Nginx 反代到 4000) +- JWT 存 localStorage key `auth`,AuthProvider 启动时验证 +- 3000 端口被占用,`next dev` 自动 fallback 到 3001 +- 路径别名 `@/` → `src/` +- Prettier: noSemi, singleQuote, trailingComma: all, printWidth: 100 +- Tailwind 设计 Token:`secondary(#0051d5)`, `border-subtle(#E2E8F0)`, `surface-container` 系 + +## 前端数据对接状态 + +| 页面 | 状态 | +|------|------| +| ComparisonPage | ✅ GET `api/comparison`,POST upload/suggestion | +| ImagePage | ✅ GET `api/images`,POST generate/parse(MiniMax 生图) | +| ProductPage | ✅ GET/POST/PATCH/DELETE `api/products` + POST upload-image | +| OverviewPage | ✅ Promise.all([GET `api/leads/stats`, GET `api/leads`]) | +| InquiryPage | ✅ GET `api/leads`,POST `api/leads/{id}/publish` | +| LeadDetailPage | ✅ GET `api/leads/{id}`,POST to-rfq/to-collab/publish/follow-up | +| RuleCenterPage | 含"采集器配置"Tab:GET/POST/PUT `api/crawlers`,POST run/pause/resume | +| ReportCenterPage | 纯 mock | +| SystemMonitorPage | 纯 mock | +| AdminPage | 已实现,5 个子区块(tenants/users/stats/configs/plans),configs 含 MCP 对接配置 | +| LoginPage | POST `api/auth/login` | + +## 演示账号 + +`admin` / `admin123`(username + password,**不是 email**;AGENTS.md 旧版写的 `admin@platform.com` 不对,后端 LoginDto 用 username 字段) + +## 数据库模型 + +User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Report, AuditLog, CrawlerConfig(含 keywords/categories + cron schedule), Inquiry, Product(含 supplier/imageUrl), GeneratedImage + +## 采集器关键事实 + +- 调度基于 `@nestjs/schedule` + `SchedulerRegistry`,`onModuleInit` 时从 DB 加载 `active` 配置注册 cron +- pause/resume/update 联动调度器的注册与注销 +- `CrawlerConfig` 的 `schedule` 字段存储 cron 表达式,前端可选:每4小时、每6小时、每天早8点、每天晚7点、手动 +- 运行采集器有即时反馈 Modal(请求完自动更新状态,也可"后台运行"关闭弹窗) +- 脚本路径相对于运行时工作目录,实际为 `scripts/tender-fetcher/` +- **通用化新增采集器**:PowerChina 保持特殊处理(JSON 格式不同)。其他网站只需: + 1. 在 `scripts/tender-fetcher/` 放脚本(如 `fetch-xxx.py`),输出 `~/desktop/招标公告//xxx_downloaded.json` + 2. 前端"采集器配置"新建记录,`type` 填 `bid_xxx`,`sourceName` 填来源显示名,`scriptPath` 填脚本文件名 + 3. 无需改代码,`runGenericScript()` 自动推断脚本和 JSON 文件路径 +- `CrawlerConfig` 新增 `sourceName`(线索来源显示名)和 `scriptPath`(脚本文件名,可选,默认从 type 推断) + +## 生产部署(腾讯云 124.222.182.242 / direv.cloud) + +- **路径**:`/opt/pingtai/`(代码) + `/data/pingtai/`(持久化数据,dev.db / uploads / library / 招标公告) +- **进程**:`pm2` 托管 `pingtai-api`(NestJS 4000)+ `pingtai-web`(Next.js 3001),systemd 自启(`/etc/systemd/system/pm2-ubuntu.service`) +- **反代**:Nginx 80/443 → 3001;`/api/*` → 4000;`/uploads/*` → `/data/pingtai/uploads/` 静态直出 +- **HTTPS**:Let's Encrypt(certbot),自动续期 timer 已开 +- **本地重部署流程**: + 1. `npm run build` 重新编译 api/web + 2. `rsync` 同步 dist/.next + 源码到 `/opt/pingtai/` + 3. `pm2 restart pingtai-api pingtai-web`(注意 `pm2 restart` 默认不更新环境变量,要 `--update-env`) + 4. Prisma 变更后必须先 `npx prisma migrate deploy && npx prisma generate`(在 `apps/api/` 下) +- **常见操作**: + - `pm2 list` / `pm2 logs` / `pm2 restart pingtai-api` + - `tail -f /var/log/pingtai/api.out-1.log` + - `sqlite3 /data/pingtai/dev.db` 直接查 DB + - 备份:`tar czf pingtai-backup-$(date +%Y%m%d).tgz /data/pingtai/` +- **历史包袱**:服务器上旧的 5月22日 Postgres/Redis 部署已备份到 `/home/ubuntu/platform-assistant.bak-*`,旧 nginx conf 在 `/etc/nginx/conf.d/platform.conf.bak-*`,如需恢复可回退 +- **采集器脚本**:`scripts/tender-fetcher/` 写出的 `~/desktop/招标公告/` 通过 symlink `~/desktop -> /data/pingtai` 落到持久化卷;symlink 必须指向 `/data/pingtai`(不是 `/data/pingtai/招标公告`),否则会双层嵌套 + +## CODE MAP + +| Symbol | Type | Location | Role | +|--------|------|----------|------| +| `AppModule` | module | apps/api/src/app.module.ts | 根模块,导入 13 个子模块 | +| `bootstrap()` | function | apps/api/src/main.ts | NestJS 应用启动入口 | +| `page.tsx` | page | apps/web/src/app/page.tsx | SPA 根,useState 切换 14 页面 | +| `AuthProvider` | context | apps/web/src/components/AuthProvider.tsx | JWT 认证上下文 | +| `CrawlerService.run()` | method | apps/api/src/crawler/crawler.service.ts | 采集器入口,execSync 调度脚本 | +| `ImageService` | class | apps/api/src/image/image.service.ts | 三阶段生图(图库→Bing→AI) | +| `runGenericScript()` | method | apps/api/src/crawler/crawler.service.ts | 通用化采集器调度 | +| `search_image.py` | script | scripts/search_image.py | Bing 搜索 top N 候选 | +| `process_image.py` | script | scripts/process_image.py | 去水印+裁剪+白底合成 | + +## product_network B2B 平台对接(2026-06-16 新增,MCP 主通道) + +### 对接架构 + +``` +平台助手 NestJS (4000) + ↓ IntegrationService.callTool() + ↓ MCP 协议 (Streamable HTTP) + ↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key) +renren-mcp-server (Node.js, devbox:3002) + ↓ 直调 Java 业务方法 + ↓ +product_network 7 个 Java 服务 +``` + +注: MCP server 端口 3002 是 **devbox 开发机** 上的端口, 不是平台助手本地端口 (3001/4000)。 + +### 设计原则 + +- **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4) +- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销) +- **APIKey 认证**:Header `x-api-key`,AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex) +- **公司账号代理**:每客户 1 个 `sys_user` (user_type=99) + +### 关键代码路径 + +| 文件 | 说明 | +|------|------| +| `apps/api/prisma/schema.prisma` | `Integration` 表(MCP 连接配置) | +| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool) | +| `apps/api/src/integration/integration.controller.ts` | REST API(CRUD + test + connect) | +| `apps/api/src/integration/integration.module.ts` | NestJS 模块 | +| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool | +| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面) | + +### 使用流程 + +1. 前端「运营管理 → 系统配置」页面顶部"MCP 对接配置"区域 +2. 点击「新增对接」,填入:名称、MCP Server 地址、APIKey、客户编码 +3. 点击「连接」验证 MCP server 可达并获取工具列表 +4. 发布询价时,自动通过 MCP 调用 `publish_rfq` tool + +### MCP Client 实现 + +```typescript +// integration.service.ts 核心逻辑 +const client = new Client({ name: 'platform-assistant', version: '1.0.0' }) +const transport = new StreamableHTTPClientTransport( + new URL(endpoint), + { requestInit: { headers: { 'x-api-key': apiKey } } } +) +await client.connect(transport) +const tools = await client.listTools() +await client.callTool({ name: 'publish_rfq', arguments: { title, ... } }) +``` + +### API 端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/integrations` | 列表 | +| POST | `/api/integrations` | 创建 | +| PUT | `/api/integrations/:id` | 更新 | +| DELETE | `/api/integrations/:id` | 删除 | +| POST | `/api/integrations/:id/connect` | 连接 MCP | +| POST | `/api/integrations/:id/disconnect` | 断开 | +| POST | `/api/integrations/test` | 测试连接 | +| POST | `/api/integrations/:id/call-tool` | 调用工具 | + +### 注意事项 + +- MCP server 地址格式:`http://host:port/mcp`(Streamable HTTP transport) +- APIKey 通过 Header `x-api-key` 传递 +- 客户编码通过 Header `x-customer-code` 传递(可选) +- **APIKey 加密**:AES-256-GCM,存 DB 为 base64 密文(iv12+tag16+ciphertext),读 API 时自动解密。旧明文数据读取时 fallback 不加密(兼容) +- 连接状态存储在数据库,服务重启后需重新连接 +- 发布失败降级为本地模式(生成假 inquiryNo) + +## ANTI-PATTERNS + +- 前端不加路由库,保持 SPA useState 模式 +- 不提交 secrets 到 git(JWT_SECRET 已在 .env.production,但 .gitignore 未排除) +- 不删除旧采集器 state 文件(中电建用 `systemId_招采公告` 作 key) +- 不依赖 Postgres/Redis(docker-compose.yml 是历史遗留,实际只用 SQLite) +- 不在后端代码中硬编码脚本路径以外的文件路径 diff --git a/apps/api/.env.production b/apps/api/.env.production new file mode 100644 index 0000000..14c582a --- /dev/null +++ b/apps/api/.env.production @@ -0,0 +1,10 @@ +# 生产环境配置 +DATABASE_URL="file:/data/pingtai/dev.db" +PORT=4000 +NODE_ENV=production +JWT_SECRET=pingtai-prod-jwt-secret-9f8e7d6c5b4a3210-change-me +JWT_EXPIRES_IN=7d +CORS_ORIGIN=https://direv.cloud,http://direv.cloud +UPLOAD_DIR=/data/pingtai/uploads +LIBRARY_DIR=/data/pingtai/library +MINIMAX_API_KEY= diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js new file mode 100644 index 0000000..92f5de6 --- /dev/null +++ b/apps/api/.eslintrc.js @@ -0,0 +1,19 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + ], + root: true, + env: { node: true, jest: true }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'off', + }, +} diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md new file mode 100644 index 0000000..aaab117 --- /dev/null +++ b/apps/api/AGENTS.md @@ -0,0 +1,55 @@ +# apps/api — NestJS 10 后端 + +**生成:** 2026-06-05 +**分支:** main + +## OVERVIEW + +NestJS 10 后端,Prisma ORM + SQLite,提供 RESTful API。端口 4000。JWT 认证。 + +## STRUCTURE + +``` +api/ +├── src/ +│ ├── admin/ # 管理后台(租户/用户/配置/计划) +│ ├── auth/ # JWT + Passport 认证 +│ ├── audit-log/ # 审计日志 +│ ├── comparison/ # 比价模块 +│ ├── crawler/ # 采集器调度 + execSync 调用脚本 +│ ├── image/ # 三阶段生图(图库→Bing→AI) +│ ├── inquiry/ # 询价 +│ ├── lead/ # 线索(核心,评分/状态流转/跟进) +│ ├── product/ # 商品 +│ ├── reports/ # 报表 +│ ├── rules/ # 规则中心 +│ ├── users/ # 用户管理 +│ ├── prisma/ # Prisma 服务封装 +│ ├── common/ # 公共装饰器/过滤器 +│ ├── app.module.ts # 根模块(导入所有子模块) +│ └── main.ts # 启动入口 +└── prisma/ + └── schema.prisma # 数据模型(~15 个模型) +``` + +## WHERE TO LOOK + +| 任务 | 位置 | Notes | +|------|------|-------| +| 新增 API 端点 | `src/xxx/xxx.controller.ts` | ValidationPipe 全局自动转换 | +| 修改数据库模型 | `prisma/schema.prisma` | 改后必须 `npm run db:init` | +| 采集器调度 | `src/crawler/` | execSync 调用 scripts/tender-fetcher/ | +| 图片生成 | `src/image/image.service.ts` | 三阶段流水线 | + +## CONVENTIONS + +- Prisma 变更三步:generate → migrate → seed(`db:init` 一站式) +- 采集器脚本路径:`scripts/tender-fetcher/` +- 无测试框架 +- 端口 4000,CORS 白名单 localhost:3000/3001 + direv.cloud +- 静态文件 `/uploads` → `public/uploads` + +## ANTI-PATTERNS + +- 不要在代码中硬编码脚本路径以外的文件路径 +- 采集器 state 文件不要手动删除(中电建用 `systemId_招采公告` 作 key) diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..26d33a8 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,49 @@ +{ + "name": "@pa/api", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "nest start --watch", + "build": "nest build", + "lint": "eslint \"{src,test}/**/*.ts\"", + "typecheck": "tsc --noEmit", + "db:migrate": "prisma migrate dev", + "db:seed": "ts-node prisma/seed.ts", + "db:generate": "prisma generate", + "clean": "rm -rf dist .turbo" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/schedule": "^6.1.3", + "@prisma/client": "^5.10.0", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/schematics": "^10.1.0", + "@types/bcrypt": "^6.0.0", + "@types/multer": "^2.1.0", + "@types/node": "^20.11.0", + "@types/passport-jwt": "^4.0.1", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.57.0", + "prettier": "^3.2.0", + "prisma": "^5.10.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0" + } +} diff --git a/apps/api/prisma/migrations/20260522142447_init/migration.sql b/apps/api/prisma/migrations/20260522142447_init/migration.sql new file mode 100644 index 0000000..0ac660b --- /dev/null +++ b/apps/api/prisma/migrations/20260522142447_init/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "Inquiry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "source" TEXT NOT NULL, + "url" TEXT NOT NULL, + "budget" TEXT, + "deadline" TEXT, + "buyer" TEXT NOT NULL, + "category" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "spec" TEXT NOT NULL, + "unit" TEXT NOT NULL, + "price" REAL NOT NULL, + "quantity" INTEGER NOT NULL, + "imageUrl" TEXT, + "category" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'draft', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "GeneratedImage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "productName" TEXT NOT NULL, + "url" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'generating', + "style" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/apps/api/prisma/migrations/20260522151230_merge_remote_logic/migration.sql b/apps/api/prisma/migrations/20260522151230_merge_remote_logic/migration.sql new file mode 100644 index 0000000..388862a --- /dev/null +++ b/apps/api/prisma/migrations/20260522151230_merge_remote_logic/migration.sql @@ -0,0 +1,127 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "brand" TEXT; +ALTER TABLE "Product" ADD COLUMN "minOrder" INTEGER; +ALTER TABLE "Product" ADD COLUMN "supplier" TEXT; + +-- CreateTable +CREATE TABLE "Lead" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "level" TEXT NOT NULL DEFAULT 'C', + "status" TEXT NOT NULL DEFAULT '待复核', + "star" INTEGER NOT NULL DEFAULT 0, + "amount" TEXT NOT NULL DEFAULT '待确认', + "category" TEXT, + "organization" TEXT, + "region" TEXT, + "source" TEXT, + "contact" TEXT, + "phone" TEXT, + "email" TEXT, + "deadline" TEXT, + "summary" TEXT, + "reason" TEXT, + "analysisLogic" TEXT, + "aiScore" REAL, + "scoringDetails" TEXT, + "publish" TEXT NOT NULL DEFAULT '未上架', + "publishedAt" DATETIME, + "inquiryNo" TEXT, + "direvUrl" TEXT, + "assignedTo" TEXT, + "followLogs" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Rule" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "config" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "userId" TEXT NOT NULL DEFAULT 'default', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "RuleVersion" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ruleId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "config" TEXT NOT NULL, + "userId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Report" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'generating', + "config" TEXT, + "channels" TEXT, + "fileUrl" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "action" TEXT NOT NULL, + "detail" TEXT NOT NULL, + "operator" TEXT NOT NULL, + "ip" TEXT, + "userId" TEXT NOT NULL DEFAULT 'default', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "CrawlerConfig" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "targetUrl" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "type" TEXT NOT NULL DEFAULT 'bid', + "schedule" TEXT, + "lastRunAt" DATETIME, + "totalRuns" INTEGER NOT NULL DEFAULT 0, + "successRuns" INTEGER NOT NULL DEFAULT 0, + "failedRuns" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Inquiry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "source" TEXT NOT NULL, + "url" TEXT NOT NULL, + "budget" TEXT, + "deadline" TEXT, + "buyer" TEXT NOT NULL, + "category" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "level" TEXT NOT NULL DEFAULT 'C', + "star" INTEGER NOT NULL DEFAULT 0, + "region" TEXT, + "contact" TEXT, + "phone" TEXT, + "aiScore" REAL, + "assignedTo" TEXT, + "inquiryNo" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Inquiry" ("budget", "buyer", "category", "createdAt", "deadline", "id", "source", "status", "title", "updatedAt", "url") SELECT "budget", "buyer", "category", "createdAt", "deadline", "id", "source", "status", "title", "updatedAt", "url" FROM "Inquiry"; +DROP TABLE "Inquiry"; +ALTER TABLE "new_Inquiry" RENAME TO "Inquiry"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/api/prisma/migrations/20260522151754_add_user_model/migration.sql b/apps/api/prisma/migrations/20260522151754_add_user_model/migration.sql new file mode 100644 index 0000000..d22c4e4 --- /dev/null +++ b/apps/api/prisma/migrations/20260522151754_add_user_model/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT, + "phone" TEXT, + "role" TEXT NOT NULL DEFAULT 'user', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/apps/api/prisma/migrations/20260526023003_add_tenant_model/migration.sql b/apps/api/prisma/migrations/20260526023003_add_tenant_model/migration.sql new file mode 100644 index 0000000..821001f --- /dev/null +++ b/apps/api/prisma/migrations/20260526023003_add_tenant_model/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "Tenant" ( + "id" TEXT NOT NULL PRIMARY KEY, + "companyName" TEXT NOT NULL, + "contactName" TEXT, + "contactEmail" TEXT, + "plan" TEXT NOT NULL DEFAULT 'basic', + "status" TEXT NOT NULL DEFAULT 'active', + "validFrom" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "validUntil" DATETIME NOT NULL, + "platformType" TEXT, + "platformUrl" TEXT, + "platformApiKey" TEXT, + "features" TEXT, + "usageQuotas" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT, + "phone" TEXT, + "role" TEXT NOT NULL DEFAULT 'user', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "tenantId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_User" ("createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "updatedAt") SELECT "createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/api/prisma/migrations/20260526024306_add_system_config/migration.sql b/apps/api/prisma/migrations/20260526024306_add_system_config/migration.sql new file mode 100644 index 0000000..775c65a --- /dev/null +++ b/apps/api/prisma/migrations/20260526024306_add_system_config/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "group" TEXT NOT NULL DEFAULT 'general', + "description" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); diff --git a/apps/api/prisma/migrations/20260526032641_add_username/migration.sql b/apps/api/prisma/migrations/20260526032641_add_username/migration.sql new file mode 100644 index 0000000..b60ea1e --- /dev/null +++ b/apps/api/prisma/migrations/20260526032641_add_username/migration.sql @@ -0,0 +1,24 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT, + "username" TEXT, + "password" TEXT NOT NULL, + "name" TEXT, + "phone" TEXT, + "role" TEXT NOT NULL DEFAULT 'user', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "tenantId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_User" ("createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "tenantId", "updatedAt") SELECT "createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "tenantId", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/api/prisma/migrations/20260527091757_add_crawler_keywords/migration.sql b/apps/api/prisma/migrations/20260527091757_add_crawler_keywords/migration.sql new file mode 100644 index 0000000..e6166e9 --- /dev/null +++ b/apps/api/prisma/migrations/20260527091757_add_crawler_keywords/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "CrawlerConfig" ADD COLUMN "categories" TEXT; +ALTER TABLE "CrawlerConfig" ADD COLUMN "keywords" TEXT; diff --git a/apps/api/prisma/migrations/20260602000000_add_image_library_and_crawler_paths/migration.sql b/apps/api/prisma/migrations/20260602000000_add_image_library_and_crawler_paths/migration.sql new file mode 100644 index 0000000..6a65b8b --- /dev/null +++ b/apps/api/prisma/migrations/20260602000000_add_image_library_and_crawler_paths/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "CrawlerConfig" ADD COLUMN "sourceName" TEXT; +ALTER TABLE "CrawlerConfig" ADD COLUMN "scriptPath" TEXT; + +-- CreateTable +CREATE TABLE "ImageLibrary" ( + "id" TEXT NOT NULL PRIMARY KEY, + "productName" TEXT NOT NULL, + "keyword" TEXT, + "filePath" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'baidu', + "style" TEXT, + "ratio" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/apps/api/prisma/migrations/20260602000001_add_lead_noticeid_detailurl/migration.sql b/apps/api/prisma/migrations/20260602000001_add_lead_noticeid_detailurl/migration.sql new file mode 100644 index 0000000..fc49df0 --- /dev/null +++ b/apps/api/prisma/migrations/20260602000001_add_lead_noticeid_detailurl/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Lead" ADD COLUMN "noticeId" TEXT; +ALTER TABLE "Lead" ADD COLUMN "detailUrl" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Lead_source_noticeId_key" ON "Lead"("source", "noticeId"); diff --git a/apps/api/prisma/migrations/20260617045342_add_app_id/migration.sql b/apps/api/prisma/migrations/20260617045342_add_app_id/migration.sql new file mode 100644 index 0000000..25f929c --- /dev/null +++ b/apps/api/prisma/migrations/20260617045342_add_app_id/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "GeneratedImage" ADD COLUMN "sourceHash" TEXT; + +-- CreateTable +CREATE TABLE "Integration" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT 'mcp', + "endpoint" TEXT NOT NULL, + "appId" TEXT, + "apiKey" TEXT, + "customerCode" TEXT, + "status" TEXT NOT NULL DEFAULT 'inactive', + "tools" TEXT, + "lastConnected" DATETIME, + "error" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5e5c47 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..78ed72a --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,229 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model Lead { + id String @id @default(uuid()) + title String + noticeId String? + detailUrl String? + level String @default("C") + status String @default("待复核") + star Int @default(0) + amount String @default("待确认") + category String? + organization String? + region String? + regionDetail String? // 详细收货地址(区别于 region 省份/区域) + source String? + contact String? + phone String? + email String? + deadline String? + quantity Int? // 询价数量 + unit String? // 单位(件/吨/米/台等) + attachments String? // JSON 数组字符串(附件 URL 列表) + summary String? + reason String? + analysisLogic String? + aiScore Float? + scoringDetails String? + publish String @default("未发布") + publishTime String? // 原始公告的发布时间(从抓取源读取) + publishedAt DateTime? + inquiryNo String? + direvUrl String? + assignedTo String? + followLogs String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([source, noticeId]) +} + +model Rule { + id String @id @default(uuid()) + name String + type String + config String + enabled Boolean @default(true) + userId String @default("default") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model RuleVersion { + id String @id @default(uuid()) + ruleId String + version Int + config String + userId String? + createdAt DateTime @default(now()) +} + +model Report { + id String @id @default(uuid()) + title String + type String + status String @default("generating") + config String? + channels String? + fileUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model AuditLog { + id String @id @default(uuid()) + action String + detail String + operator String + ip String? + userId String @default("default") + createdAt DateTime @default(now()) +} + +model CrawlerConfig { + id String @id @default(uuid()) + name String + targetUrl String + status String @default("active") + type String @default("bid") + sourceName String? + scriptPath String? + schedule String? + keywords String? + categories String? + lastRunAt DateTime? + totalRuns Int @default(0) + successRuns Int @default(0) + failedRuns Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Inquiry { + id String @id @default(uuid()) + title String + source String + url String + budget String? + deadline String? + buyer String + category String + status String @default("pending") + level String @default("C") + star Int @default(0) + region String? + contact String? + phone String? + aiScore Float? + assignedTo String? + inquiryNo String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Product { + id String @id @default(uuid()) + name String + spec String + unit String + price Float + quantity Int + imageUrl String? + category String + status String @default("draft") + brand String? + supplier String? + minOrder Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Tenant { + id String @id @default(uuid()) + companyName String + contactName String? + contactEmail String? + plan String @default("basic") + status String @default("active") + validFrom DateTime @default(now()) + validUntil DateTime + platformType String? + platformUrl String? + platformApiKey String? + features String? + usageQuotas String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] +} + +model User { + id String @id @default(uuid()) + email String? @unique + username String? @unique + password String + name String? + phone String? + role String @default("user") + isActive Boolean @default(true) + tenantId String? + tenant Tenant? @relation(fields: [tenantId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model GeneratedImage { + id String @id @default(uuid()) + productName String + url String + status String @default("generating") + style String? + sourceHash String? + createdAt DateTime @default(now()) +} + +model ImageLibrary { + id String @id @default(uuid()) + productName String + keyword String? + filePath String + source String @default("baidu") + style String? + ratio String? + createdAt DateTime @default(now()) +} + +model SystemConfig { + id String @id @default(uuid()) + key String @unique + value String + group String @default("general") + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Integration { + id String @id @default(uuid()) + name String // 对接名称,如 "B2B 询价平台" + type String @default("mcp") // "mcp" | "http" | "custom" + endpoint String // MCP server 地址 (devbox 开发机), 如 "http://100.111.84.16:3002/mcp" + appId String? // B2B 运营后台"集成管理"申请到的 appId (公开 ID, 非敏感) + apiKey String? // appSecret (加密存储) - 对应 B2B 运营后台 app_secret + customerCode String? // 客户编码 (冗余, 实际由 MCP 验证后从 X-Customer-Code 头获取) + status String @default("inactive") // "active" | "inactive" | "error" + tools String? // JSON: 可用工具列表 + lastConnected DateTime? // 最后成功连接时间 + error String? // 最近错误信息 + companyUserId String? // W31: 公司账号 ID (从 B2B 后端 open_api_app 表来, 用于一键发布时自动填 enterpriseId/userId; 用 String 存避免 JS Number 精度丢失) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..6f89086 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,269 @@ +import { PrismaClient } from '@prisma/client' +import * as bcrypt from 'bcrypt' +const prisma = new PrismaClient() + +async function main() { + const hashed = await bcrypt.hash('admin123', 10) + + // ---- 租户 ---- + const demoCompany = await prisma.tenant.upsert({ + where: { id: 'demo-tenant-001' }, + update: {}, + create: { + id: 'demo-tenant-001', + companyName: '示例科技公司', contactName: '张三', contactEmail: 'contact@example.com', + plan: 'enterprise', status: 'active', + validFrom: new Date('2026-01-01'), validUntil: new Date('2027-01-01'), + platformType: 'jubian', platformUrl: 'https://jubian.example.com', + features: JSON.stringify({ inquiry: true, imageGen: true, product: true, comparison: true }), + usageQuotas: JSON.stringify({ monthlyLeads: 10000, monthlyImages: 500, monthlyProducts: 5000, monthlyComparisons: 200, maxUsers: 50 }), + }, + }) + + const farEast = await prisma.tenant.upsert({ + where: { id: 'demo-tenant-002' }, + update: {}, + create: { + id: 'demo-tenant-002', + companyName: '远东电缆有限公司', contactName: '李四', contactEmail: 'li@fareast-cable.com', + plan: 'pro', status: 'active', + validFrom: new Date('2026-03-01'), validUntil: new Date('2026-05-31'), + platformType: 'custom', platformUrl: 'https://erp.fareast-cable.com', + features: JSON.stringify({ inquiry: true, imageGen: false, product: true, comparison: true }), + usageQuotas: JSON.stringify({ monthlyLeads: 5000, monthlyImages: 0, monthlyProducts: 2000, monthlyComparisons: 100, maxUsers: 20 }), + }, + }) + + const shangshang = await prisma.tenant.upsert({ + where: { id: 'demo-tenant-003' }, + update: {}, + create: { + id: 'demo-tenant-003', + companyName: '上上电缆集团', contactName: '王五', contactEmail: 'wang@shangshang.com', + plan: 'basic', status: 'active', + validFrom: new Date('2026-04-01'), validUntil: new Date('2027-04-01'), + features: JSON.stringify({ inquiry: true, imageGen: false, product: true, comparison: false }), + usageQuotas: JSON.stringify({ monthlyLeads: 1000, monthlyImages: 0, monthlyProducts: 500, monthlyComparisons: 0, maxUsers: 5 }), + }, + }) + + const minxing = await prisma.tenant.upsert({ + where: { id: 'demo-tenant-004' }, + update: {}, + create: { + id: 'demo-tenant-004', + companyName: '民兴电缆科技', contactName: '赵六', contactEmail: 'zhao@minxing.com', + plan: 'basic', status: 'expired', + validFrom: new Date('2025-01-01'), validUntil: new Date('2026-01-01'), + features: JSON.stringify({ inquiry: true, imageGen: false, product: false, comparison: false }), + usageQuotas: JSON.stringify({ monthlyLeads: 500, monthlyImages: 0, monthlyProducts: 0, monthlyComparisons: 0, maxUsers: 3 }), + }, + }) + + // ---- 用户 ---- + await prisma.user.upsert({ + where: { email: 'admin@platform.com' }, + update: {}, + create: { email: 'admin@platform.com', username: 'admin', password: hashed, name: '管理员', role: 'admin', tenantId: demoCompany.id }, + }) + await prisma.user.upsert({ + where: { email: 'user@example.com' }, + update: {}, + create: { email: 'user@example.com', username: 'user', password: hashed, name: '测试用户', role: 'user', tenantId: demoCompany.id }, + }) + await prisma.user.upsert({ + where: { email: 'li@fareast-cable.com' }, + update: {}, + create: { email: 'li@fareast-cable.com', username: 'li.fareast', password: hashed, name: '李四', role: 'user', tenantId: farEast.id }, + }) + await prisma.user.upsert({ + where: { email: 'wang@shangshang.com' }, + update: {}, + create: { email: 'wang@shangshang.com', username: 'wang', password: hashed, name: '王五', role: 'user', tenantId: shangshang.id }, + }) + await prisma.user.upsert({ + where: { email: 'zhao@minxing.com' }, + update: {}, + create: { email: 'zhao@minxing.com', username: 'zhao', password: hashed, name: '赵六', role: 'user', tenantId: minxing.id }, + }) + await prisma.rule.createMany({ + data: [ + { name: '综合评分权重', type: 'scoring', config: JSON.stringify({ weights: [{ field: '预估金额', weight: 30 }, { field: '匹配度', weight: 25 }, { field: '甲方级别', weight: 20 }, { field: '时效性', weight: 15 }, { field: '历史成交', weight: 10 }] }), enabled: true }, + { name: '防水材料关键词库', type: 'keyword', config: JSON.stringify({ keywords: ['防水卷材', '防水涂料', '聚氨酯', 'SBS改性沥青', '高分子防水'], category: '防水材料', priority: 8 }), enabled: true }, + { name: '电力设备关键词库', type: 'keyword', config: JSON.stringify({ keywords: ['变压器', '断路器', '电缆', '开关柜', 'GIS'], category: '电力设备', priority: 9 }), enabled: true }, + { name: '钢材关键词库', type: 'keyword', config: JSON.stringify({ keywords: ['钢板', '钢管', '型钢', '螺纹钢', 'H型钢'], category: '钢材', priority: 7 }), enabled: true }, + { name: '甲方分级标准', type: 'client_level', config: JSON.stringify({ levels: [{ name: 'S级', minAmount: 1000000, minCooperations: 3 }, { name: 'A级', minAmount: 500000, minCooperations: 1 }, { name: 'B级', minAmount: 100000 }, { name: 'C级', minAmount: 10000 }, { name: 'D级' }] }), enabled: true }, + ], + }) + + await prisma.report.createMany({ + data: [ + { title: '5月第3周线索数据报告', type: '线索', status: '已完成', config: JSON.stringify({ dateRange: '2024-05-13~2024-05-19', sources: ['中国采购与招标网', '中国电建阳光采购网'], categories: ['电力设备', '钢材', 'IT设备'] }), channels: JSON.stringify(['飞书', '邮箱']) }, + { title: '5月询价发布统计', type: '询价', status: '已完成', config: JSON.stringify({ dateRange: '2024-05-01~2024-05-20' }) }, + { title: '本周商品上架报告', type: '商品', status: '生成中', config: JSON.stringify({ dateRange: '2024-05-20~2024-05-22' }) }, + ], + }) + + await prisma.auditLog.createMany({ + data: [ + { action: '登录', detail: '用户 admin 登录系统', operator: 'admin', ip: '192.168.1.100' }, + { action: '修改规则', detail: '修改了评分权重配置', operator: 'admin', ip: '192.168.1.100' }, + { action: '导出报告', detail: '导出了5月第3周线索数据报告', operator: 'admin', ip: '192.168.1.100' }, + { action: '删除线索', detail: '删除了2条无效线索', operator: 'admin', ip: '192.168.1.100' }, + { action: '批量上架', detail: '批量上架了5个商品', operator: 'admin', ip: '192.168.1.100' }, + { action: '修改规则', detail: '新增了钢材品类关键词', operator: 'admin', ip: '192.168.1.100' }, + { action: '导出报告', detail: '导出了5月询价发布统计', operator: 'admin', ip: '192.168.1.100' }, + { action: '登录', detail: '用户 admin 登录系统', operator: 'admin', ip: '192.168.1.100' }, + ], + }) + + await prisma.crawlerConfig.deleteMany() + await prisma.crawlerConfig.createMany({ + data: [ + { name: '中国电建阳光采购网', targetUrl: 'https://bid.powerchina.cn', type: 'bid_powerchina', sourceName: '中电建', status: 'active', schedule: '0 8 * * 1-5', totalRuns: 286, successRuns: 275, failedRuns: 11, keywords: '["电缆","防火门","配电箱","钢材"]' }, + { name: '新矿集团供应链平台', targetUrl: 'https://xkgroup.kdcloud.com', type: 'bid_xkgroup', sourceName: '新矿集团', scriptPath: 'fetch-xkgroup.py', status: 'active', schedule: '5 8 * * 1-5', totalRuns: 142, successRuns: 138, failedRuns: 4, keywords: '["电缆","钢材","阀门","管材"]' }, + { name: '中核集团电子采购平台', targetUrl: 'https://www.cnncecp.com', type: 'bid_cnncecp', sourceName: '中核集团', scriptPath: 'fetch-cnncecp.py', status: 'paused', totalRuns: 0, successRuns: 0, failedRuns: 0, keywords: '["电缆","防火门","钢材","阀门"]' }, + ], + }) + + await prisma.inquiry.createMany({ + data: [ + { title: '2024年电力电缆框架采购项目', source: '中国电建阳光采购网', url: 'https://example.com/inquiry/001', budget: '500万元', deadline: '2024-12-31', buyer: '中国电力建设集团', category: '电力设备', status: 'pending', level: 'S', star: 5, region: '北京' }, + { title: '钢结构桥梁用钢板采购招标', source: '中国中铁采购电子商务平台', url: 'https://example.com/inquiry/002', budget: '1200万元', deadline: '2024-11-15', buyer: '中铁大桥局', category: '钢材', status: 'published', level: 'A', star: 4, region: '湖北武汉' }, + { title: '数据中心服务器及网络设备采购', source: '中国移动采购与招标网', url: 'https://example.com/inquiry/003', budget: '800万元', deadline: '2024-10-20', buyer: '中国移动通信集团', category: 'IT设备', status: 'pending', level: 'S', star: 5, region: '北京' }, + ], + }) + + const cableSKUs = [ + { name: 'YJV-0.6/1kV 3×4 电力电缆', spec: '0.6/1kV 3×4mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV-0.6/1kV 3×6 电力电缆', spec: '0.6/1kV 3×6mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV-0.6/1kV 3×10 电力电缆', spec: '0.6/1kV 3×10mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV22-8.7/15kV 3×70 电力电缆', spec: '8.7/15kV 3×70mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV22-8.7/15kV 3×95 电力电缆', spec: '8.7/15kV 3×95mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV22-8.7/15kV 3×120 电力电缆', spec: '8.7/15kV 3×120mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV22-8.7/15kV 3×150 电力电缆', spec: '8.7/15kV 3×150mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV22-8.7/15kV 3×185 电力电缆', spec: '8.7/15kV 3×185mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV22-8.7/15kV 3×240 电力电缆', spec: '8.7/15kV 3×240mm²', unit: '米', category: '电力电缆' }, + { name: 'YJV22-8.7/15kV 3×300 电力电缆', spec: '8.7/15kV 3×300mm²', unit: '米', category: '电力电缆' }, + { name: 'BV-450/750V 2.5mm² 家装电线', spec: '450/750V 2.5mm²', unit: '米', category: '家装电线' }, + { name: 'BV-450/750V 4mm² 家装电线', spec: '450/750V 4mm²', unit: '米', category: '家装电线' }, + { name: 'BV-450/750V 6mm² 家装电线', spec: '450/750V 6mm²', unit: '米', category: '家装电线' }, + { name: 'KVVP-0.45/0.75kV 14×1.5 控制电缆', spec: '0.45/0.75kV 14×1.5mm²', unit: '米', category: '控制电缆' }, + { name: 'RVVP-300/300V 4×0.75 屏蔽软电缆', spec: '300/300V 4×0.75mm²', unit: '米', category: '控制电缆' }, + ] + + const suppliers = [ + { name: '远东电缆', brand: '远东电缆' }, + { name: '上上电缆', brand: '上上电缆' }, + { name: '民兴电缆', brand: '民兴电缆' }, + { name: '金杯电缆', brand: '金杯电缆' }, + { name: '金龙羽', brand: '金龙羽' }, + ] + + // 价格矩阵:每行是一个商品,每列是对应供应商的价格 + // 设计逻辑:每个供应商在不同商品上有价格优势,使得 2/3/4 家组合的最优方案不同 + // 远东: 商品0-2最便宜 | 上上: 商品3-5最便宜 | 民兴: 商品6-8最便宜 | 金杯: 商品9-11最便宜 | 金龙羽: 商品12-14最便宜 + const priceMatrix: number[][] = [ + // SKU 远东 上上 民兴 金杯 金龙羽 + /* YJV 3×4 电力电缆 */ [12.00, 12.80, 13.50, 13.20, 14.00], + /* YJV 3×6 电力电缆 */ [18.00, 19.20, 20.10, 19.50, 21.00], + /* YJV 3×10 电力电缆 */ [27.50, 29.00, 30.50, 29.80, 32.00], + /* YJV22 3×70 电力电缆 */ [190.00, 182.00, 195.00, 188.00, 200.00], + /* YJV22 3×95 电力电缆 */ [250.00, 238.00, 260.00, 248.00, 265.00], + /* YJV22 3×120 电力电缆 */[320.00, 305.00, 330.00, 315.00, 340.00], + /* YJV22 3×150 电力电缆 */[410.00, 395.00, 385.00, 400.00, 420.00], + /* YJV22 3×185 电力电缆 */[500.00, 480.00, 465.00, 475.00, 510.00], + /* YJV22 3×240 电力电缆 */[650.00, 630.00, 615.00, 625.00, 670.00], + /* YJV22 3×300 电力电缆 */[810.00, 790.00, 780.00, 770.00, 830.00], + /* BV 2.5mm² 家装电线 */ [1.85, 1.80, 1.75, 1.68, 1.90], + /* BV 4mm² 家装电线 */ [2.70, 2.60, 2.55, 2.45, 2.80], + /* BV 6mm² 家装电线 */ [4.30, 4.15, 4.10, 3.95, 4.50], + /* KVVP 控制电缆 */ [40.00, 38.50, 37.00, 36.00, 42.00], + /* RVVP 屏蔽软电缆 */ [23.00, 22.00, 21.50, 21.00, 24.00], + ] + + // 每个商品的数量 + const quantities = [3000, 2500, 2000, 1500, 1200, 1000, 800, 600, 500, 400, 5000, 4000, 3000, 800, 600] + + const productData: any[] = [] + for (let si = 0; si < suppliers.length; si++) { + const s = suppliers[si] + for (let pi = 0; pi < cableSKUs.length; pi++) { + productData.push({ + name: cableSKUs[pi].name, + spec: cableSKUs[pi].spec, + unit: cableSKUs[pi].unit, + category: cableSKUs[pi].category, + price: priceMatrix[pi][si], + quantity: quantities[pi], + status: 'draft', + brand: s.brand, + supplier: s.name, + minOrder: 100, + }) + } + } + + await prisma.product.deleteMany() + await prisma.product.createMany({ data: productData }) + + // Default system configs + const defaultConfigs = [ + { key: 'llm_provider', value: 'minimax', group: 'ai', description: '大模型供应商(minimax / openai)' }, + { key: 'llm_model', value: 'abab-6.5s-chat', group: 'ai', description: '对话模型名称' }, + { key: 'image_provider', value: 'minimax', group: 'ai', description: '图片生成供应商(minimax)' }, + { key: 'mmx_group_id', value: 'mg-20260301-abc123', group: 'ai', description: 'MiniMax 分组 ID' }, + { key: 'mmx_api_key', value: 'sk-mmx-xxxxxxxxxxxxxxxx', group: 'ai', description: 'MiniMax API 密钥' }, + { key: 'jubian_api_url', value: 'https://api.jubian.com/v1', group: 'platform', description: '巨变平台 API 地址' }, + { key: 'jubian_api_key', value: 'jb-sk-xxxxxxxxxxxxxxxx', group: 'platform', description: '巨变平台 API 密钥' }, + { key: 'custom_api_url', value: '', group: 'platform', description: '自建平台 API 地址' }, + { key: 'custom_api_key', value: '', group: 'platform', description: '自建平台 API 密钥' }, + { key: 'upload_max_size', value: '20', group: 'system', description: '上传文件大小限制(MB)' }, + { key: 'session_timeout', value: '120', group: 'system', description: '会话超时时间(分钟)' }, + { key: 'maintenance_mode', value: 'false', group: 'system', description: '系统维护模式(true/false)' }, + ] + for (const cfg of defaultConfigs) { + await prisma.systemConfig.upsert({ + where: { key: cfg.key }, + update: {}, + create: cfg, + }) + } + + // Default plan config + await prisma.systemConfig.upsert({ + where: { key: 'plan_config' }, + update: {}, + create: { + key: 'plan_config', + value: JSON.stringify({ + basic: { + label: '基础版', price: 3000, description: '小型团队起步', + features: { inquiry: true, imageGen: false, product: true, comparison: false }, + quotas: { monthlyLeads: 100, monthlyImages: 0, monthlyProducts: 100, monthlyComparisons: 0, maxUsers: 5 }, + platformCount: 1, supportLevel: '社区支持', + }, + pro: { + label: '专业版', price: 6000, description: '中型企业', + features: { inquiry: true, imageGen: true, product: true, comparison: true }, + quotas: { monthlyLeads: 500, monthlyImages: 50, monthlyProducts: 500, monthlyComparisons: 100, maxUsers: 20 }, + platformCount: 3, supportLevel: '标准支持', + }, + enterprise: { + label: '旗舰版', price: 9000, description: '大型企业/多租户', + features: { inquiry: true, imageGen: true, product: true, comparison: true }, + quotas: { monthlyLeads: 99999, monthlyImages: 99999, monthlyProducts: 99999, monthlyComparisons: 99999, maxUsers: 99999 }, + platformCount: 999, supportLevel: '优先技术支持', + }, + }), + group: 'system', + description: '订阅方案配置(JSON)', + }, + }) + + console.log('Seed data created') +} + +main() + .catch((e) => console.error(e)) + .finally(() => prisma.$disconnect()) diff --git a/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1780361209195.jpg b/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1780361209195.jpg new file mode 100644 index 0000000..71e4e26 Binary files /dev/null and b/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1780361209195.jpg differ diff --git a/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1782112752323.jpg b/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1782112752323.jpg new file mode 100644 index 0000000..1ab4be2 Binary files /dev/null and b/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1782112752323.jpg differ diff --git a/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1782112874397.jpg b/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1782112874397.jpg new file mode 100644 index 0000000..f3460f4 Binary files /dev/null and b/apps/api/public/uploads/library/YJV-0_6_1kV_3_4_电力电缆_1782112874397.jpg differ diff --git a/apps/api/public/uploads/library/安全防坠链条_1782112839566.jpg b/apps/api/public/uploads/library/安全防坠链条_1782112839566.jpg new file mode 100644 index 0000000..4851d0c Binary files /dev/null and b/apps/api/public/uploads/library/安全防坠链条_1782112839566.jpg differ diff --git a/apps/api/public/uploads/library/开关箱_1782112687162.jpg b/apps/api/public/uploads/library/开关箱_1782112687162.jpg new file mode 100644 index 0000000..ecf83d1 Binary files /dev/null and b/apps/api/public/uploads/library/开关箱_1782112687162.jpg differ diff --git a/apps/api/public/uploads/library/彩条布_1782112637957.jpg b/apps/api/public/uploads/library/彩条布_1782112637957.jpg new file mode 100644 index 0000000..15056b3 Binary files /dev/null and b/apps/api/public/uploads/library/彩条布_1782112637957.jpg differ diff --git a/apps/api/public/uploads/library/接地针_1782112826904.jpg b/apps/api/public/uploads/library/接地针_1782112826904.jpg new file mode 100644 index 0000000..5fb6cb0 Binary files /dev/null and b/apps/api/public/uploads/library/接地针_1782112826904.jpg differ diff --git a/apps/api/public/uploads/library/电力电缆_1780015856016.jpg b/apps/api/public/uploads/library/电力电缆_1780015856016.jpg new file mode 100644 index 0000000..b163a78 Binary files /dev/null and b/apps/api/public/uploads/library/电力电缆_1780015856016.jpg differ diff --git a/apps/api/public/uploads/library/盖土网_1782112623323.jpg b/apps/api/public/uploads/library/盖土网_1782112623323.jpg new file mode 100644 index 0000000..97b6d04 Binary files /dev/null and b/apps/api/public/uploads/library/盖土网_1782112623323.jpg differ diff --git a/apps/api/public/uploads/library/线缆支架_1782112752321.jpg b/apps/api/public/uploads/library/线缆支架_1782112752321.jpg new file mode 100644 index 0000000..f9d69c4 Binary files /dev/null and b/apps/api/public/uploads/library/线缆支架_1782112752321.jpg differ diff --git a/apps/api/public/uploads/library/螺纹钢_1780021781427.jpg b/apps/api/public/uploads/library/螺纹钢_1780021781427.jpg new file mode 100644 index 0000000..7bf1be6 Binary files /dev/null and b/apps/api/public/uploads/library/螺纹钢_1780021781427.jpg differ diff --git a/apps/api/public/uploads/library/螺纹钢_1780469942307.jpg b/apps/api/public/uploads/library/螺纹钢_1780469942307.jpg new file mode 100644 index 0000000..0f8cf8d Binary files /dev/null and b/apps/api/public/uploads/library/螺纹钢_1780469942307.jpg differ diff --git a/apps/api/public/uploads/library/配电箱_1780470336247.jpg b/apps/api/public/uploads/library/配电箱_1780470336247.jpg new file mode 100644 index 0000000..50e2e7d Binary files /dev/null and b/apps/api/public/uploads/library/配电箱_1780470336247.jpg differ diff --git a/apps/api/public/uploads/library/配电箱_1780911303106.jpg b/apps/api/public/uploads/library/配电箱_1780911303106.jpg new file mode 100644 index 0000000..f4eb0d4 Binary files /dev/null and b/apps/api/public/uploads/library/配电箱_1780911303106.jpg differ diff --git a/apps/api/public/uploads/library/钢材_1780027888402.jpg b/apps/api/public/uploads/library/钢材_1780027888402.jpg new file mode 100644 index 0000000..813a6af Binary files /dev/null and b/apps/api/public/uploads/library/钢材_1780027888402.jpg differ diff --git a/apps/api/public/uploads/library/钢材_1780301816602.jpg b/apps/api/public/uploads/library/钢材_1780301816602.jpg new file mode 100644 index 0000000..121a748 Binary files /dev/null and b/apps/api/public/uploads/library/钢材_1780301816602.jpg differ diff --git a/apps/api/public/uploads/library/钢材_1780303172953.jpg b/apps/api/public/uploads/library/钢材_1780303172953.jpg new file mode 100644 index 0000000..ad4131d Binary files /dev/null and b/apps/api/public/uploads/library/钢材_1780303172953.jpg differ diff --git a/apps/api/public/uploads/library/阀门_1780022802575.jpg b/apps/api/public/uploads/library/阀门_1780022802575.jpg new file mode 100644 index 0000000..e9b6c63 Binary files /dev/null and b/apps/api/public/uploads/library/阀门_1780022802575.jpg differ diff --git a/apps/api/public/uploads/library/阀门_1780303259334.jpg b/apps/api/public/uploads/library/阀门_1780303259334.jpg new file mode 100644 index 0000000..a2e8d14 Binary files /dev/null and b/apps/api/public/uploads/library/阀门_1780303259334.jpg differ diff --git a/apps/api/public/uploads/library/阀门_1780361226104.jpg b/apps/api/public/uploads/library/阀门_1780361226104.jpg new file mode 100644 index 0000000..333f3e8 Binary files /dev/null and b/apps/api/public/uploads/library/阀门_1780361226104.jpg differ diff --git a/apps/api/public/uploads/library/阀门_1780361234877.jpg b/apps/api/public/uploads/library/阀门_1780361234877.jpg new file mode 100644 index 0000000..333f3e8 Binary files /dev/null and b/apps/api/public/uploads/library/阀门_1780361234877.jpg differ diff --git a/apps/api/public/uploads/library/防水涂料_1780023753944.jpg b/apps/api/public/uploads/library/防水涂料_1780023753944.jpg new file mode 100644 index 0000000..b703ce9 Binary files /dev/null and b/apps/api/public/uploads/library/防水涂料_1780023753944.jpg differ diff --git a/apps/api/public/uploads/library/防水涂料_1780299916714.jpg b/apps/api/public/uploads/library/防水涂料_1780299916714.jpg new file mode 100644 index 0000000..67cdb5d Binary files /dev/null and b/apps/api/public/uploads/library/防水涂料_1780299916714.jpg differ diff --git a/apps/api/public/uploads/library/防水涂料_1780303134297.jpg b/apps/api/public/uploads/library/防水涂料_1780303134297.jpg new file mode 100644 index 0000000..d7aeda1 Binary files /dev/null and b/apps/api/public/uploads/library/防水涂料_1780303134297.jpg differ diff --git a/apps/api/public/uploads/library/防火门_1780021074049.jpg b/apps/api/public/uploads/library/防火门_1780021074049.jpg new file mode 100644 index 0000000..0ede9e2 Binary files /dev/null and b/apps/api/public/uploads/library/防火门_1780021074049.jpg differ diff --git a/apps/api/public/uploads/library/防火门_1780322405325.jpg b/apps/api/public/uploads/library/防火门_1780322405325.jpg new file mode 100644 index 0000000..e7560b4 Binary files /dev/null and b/apps/api/public/uploads/library/防火门_1780322405325.jpg differ diff --git a/apps/api/public/uploads/library/防火门_1780469738173.jpg b/apps/api/public/uploads/library/防火门_1780469738173.jpg new file mode 100644 index 0000000..6823015 Binary files /dev/null and b/apps/api/public/uploads/library/防火门_1780469738173.jpg differ diff --git a/apps/api/public/uploads/library/防火门_1780469792215.jpg b/apps/api/public/uploads/library/防火门_1780469792215.jpg new file mode 100644 index 0000000..50c27b7 Binary files /dev/null and b/apps/api/public/uploads/library/防火门_1780469792215.jpg differ diff --git a/apps/api/public/uploads/product_1780041241207_e7bwwz.png b/apps/api/public/uploads/product_1780041241207_e7bwwz.png new file mode 100644 index 0000000..fa9c8f8 Binary files /dev/null and b/apps/api/public/uploads/product_1780041241207_e7bwwz.png differ diff --git a/apps/api/public/uploads/安全防坠链条_1779693726664_001.jpg b/apps/api/public/uploads/安全防坠链条_1779693726664_001.jpg new file mode 100644 index 0000000..4c55b34 Binary files /dev/null and b/apps/api/public/uploads/安全防坠链条_1779693726664_001.jpg differ diff --git a/apps/api/public/uploads/安全防坠链条_1779693745272_001.jpg b/apps/api/public/uploads/安全防坠链条_1779693745272_001.jpg new file mode 100644 index 0000000..e7159a2 Binary files /dev/null and b/apps/api/public/uploads/安全防坠链条_1779693745272_001.jpg differ diff --git a/apps/api/public/uploads/安全防坠链条_1779780150671_001.jpg b/apps/api/public/uploads/安全防坠链条_1779780150671_001.jpg new file mode 100644 index 0000000..77ecc2d Binary files /dev/null and b/apps/api/public/uploads/安全防坠链条_1779780150671_001.jpg differ diff --git a/apps/api/public/uploads/安全防坠链条_1779780168617_001.jpg b/apps/api/public/uploads/安全防坠链条_1779780168617_001.jpg new file mode 100644 index 0000000..8c7e012 Binary files /dev/null and b/apps/api/public/uploads/安全防坠链条_1779780168617_001.jpg differ diff --git a/apps/api/public/uploads/安全防坠链条_1779958038198_001.jpg b/apps/api/public/uploads/安全防坠链条_1779958038198_001.jpg new file mode 100644 index 0000000..69debdb Binary files /dev/null and b/apps/api/public/uploads/安全防坠链条_1779958038198_001.jpg differ diff --git a/apps/api/public/uploads/安全防坠链条_1779958057126_001.jpg b/apps/api/public/uploads/安全防坠链条_1779958057126_001.jpg new file mode 100644 index 0000000..eaef45e Binary files /dev/null and b/apps/api/public/uploads/安全防坠链条_1779958057126_001.jpg differ diff --git a/apps/api/public/uploads/开关箱_1779693674142_001.jpg b/apps/api/public/uploads/开关箱_1779693674142_001.jpg new file mode 100644 index 0000000..30c70a6 Binary files /dev/null and b/apps/api/public/uploads/开关箱_1779693674142_001.jpg differ diff --git a/apps/api/public/uploads/开关箱_1779779910611_001.jpg b/apps/api/public/uploads/开关箱_1779779910611_001.jpg new file mode 100644 index 0000000..c52ef80 Binary files /dev/null and b/apps/api/public/uploads/开关箱_1779779910611_001.jpg differ diff --git a/apps/api/public/uploads/开关箱_1779780096575_001.jpg b/apps/api/public/uploads/开关箱_1779780096575_001.jpg new file mode 100644 index 0000000..4b7060d Binary files /dev/null and b/apps/api/public/uploads/开关箱_1779780096575_001.jpg differ diff --git a/apps/api/public/uploads/开关箱_1779957977814_001.jpg b/apps/api/public/uploads/开关箱_1779957977814_001.jpg new file mode 100644 index 0000000..738dddd Binary files /dev/null and b/apps/api/public/uploads/开关箱_1779957977814_001.jpg differ diff --git a/apps/api/public/uploads/开关箱_1782112733419_001.jpg b/apps/api/public/uploads/开关箱_1782112733419_001.jpg new file mode 100644 index 0000000..ecf83d1 Binary files /dev/null and b/apps/api/public/uploads/开关箱_1782112733419_001.jpg differ diff --git a/apps/api/public/uploads/彩条布_1779693621007_001.jpg b/apps/api/public/uploads/彩条布_1779693621007_001.jpg new file mode 100644 index 0000000..5169108 Binary files /dev/null and b/apps/api/public/uploads/彩条布_1779693621007_001.jpg differ diff --git a/apps/api/public/uploads/彩条布_1779779856620_001.jpg b/apps/api/public/uploads/彩条布_1779779856620_001.jpg new file mode 100644 index 0000000..6247fbd Binary files /dev/null and b/apps/api/public/uploads/彩条布_1779779856620_001.jpg differ diff --git a/apps/api/public/uploads/彩条布_1779780041212_001.jpg b/apps/api/public/uploads/彩条布_1779780041212_001.jpg new file mode 100644 index 0000000..4759a8c Binary files /dev/null and b/apps/api/public/uploads/彩条布_1779780041212_001.jpg differ diff --git a/apps/api/public/uploads/彩条布_1779957918022_001.jpg b/apps/api/public/uploads/彩条布_1779957918022_001.jpg new file mode 100644 index 0000000..90bcad4 Binary files /dev/null and b/apps/api/public/uploads/彩条布_1779957918022_001.jpg differ diff --git a/apps/api/public/uploads/彩条布_1782112667969_001.jpg b/apps/api/public/uploads/彩条布_1782112667969_001.jpg new file mode 100644 index 0000000..15056b3 Binary files /dev/null and b/apps/api/public/uploads/彩条布_1782112667969_001.jpg differ diff --git a/apps/api/public/uploads/接地针_1779693708336_001.jpg b/apps/api/public/uploads/接地针_1779693708336_001.jpg new file mode 100644 index 0000000..dff891e Binary files /dev/null and b/apps/api/public/uploads/接地针_1779693708336_001.jpg differ diff --git a/apps/api/public/uploads/接地针_1779780132449_001.jpg b/apps/api/public/uploads/接地针_1779780132449_001.jpg new file mode 100644 index 0000000..2669742 Binary files /dev/null and b/apps/api/public/uploads/接地针_1779780132449_001.jpg differ diff --git a/apps/api/public/uploads/接地针_1779958018209_001.jpg b/apps/api/public/uploads/接地针_1779958018209_001.jpg new file mode 100644 index 0000000..e44f0b2 Binary files /dev/null and b/apps/api/public/uploads/接地针_1779958018209_001.jpg differ diff --git a/apps/api/public/uploads/消防防汛专用沙袋_1779693762535_001.jpg b/apps/api/public/uploads/消防防汛专用沙袋_1779693762535_001.jpg new file mode 100644 index 0000000..b6294e9 Binary files /dev/null and b/apps/api/public/uploads/消防防汛专用沙袋_1779693762535_001.jpg differ diff --git a/apps/api/public/uploads/消防防汛专用沙袋_1779958076379_001.jpg b/apps/api/public/uploads/消防防汛专用沙袋_1779958076379_001.jpg new file mode 100644 index 0000000..2d3b9e3 Binary files /dev/null and b/apps/api/public/uploads/消防防汛专用沙袋_1779958076379_001.jpg differ diff --git a/apps/api/public/uploads/盖土网_1779693538234_001.jpg b/apps/api/public/uploads/盖土网_1779693538234_001.jpg new file mode 100644 index 0000000..83dbc87 Binary files /dev/null and b/apps/api/public/uploads/盖土网_1779693538234_001.jpg differ diff --git a/apps/api/public/uploads/盖土网_1779693602958_001.jpg b/apps/api/public/uploads/盖土网_1779693602958_001.jpg new file mode 100644 index 0000000..9e4b150 Binary files /dev/null and b/apps/api/public/uploads/盖土网_1779693602958_001.jpg differ diff --git a/apps/api/public/uploads/盖土网_1779779837708_001.jpg b/apps/api/public/uploads/盖土网_1779779837708_001.jpg new file mode 100644 index 0000000..62efe8c Binary files /dev/null and b/apps/api/public/uploads/盖土网_1779779837708_001.jpg differ diff --git a/apps/api/public/uploads/盖土网_1779780024276_001.jpg b/apps/api/public/uploads/盖土网_1779780024276_001.jpg new file mode 100644 index 0000000..095be90 Binary files /dev/null and b/apps/api/public/uploads/盖土网_1779780024276_001.jpg differ diff --git a/apps/api/public/uploads/盖土网_1779957897377_001.jpg b/apps/api/public/uploads/盖土网_1779957897377_001.jpg new file mode 100644 index 0000000..f1e8ef0 Binary files /dev/null and b/apps/api/public/uploads/盖土网_1779957897377_001.jpg differ diff --git a/apps/api/public/uploads/线缆支架_1779693690954_001.jpg b/apps/api/public/uploads/线缆支架_1779693690954_001.jpg new file mode 100644 index 0000000..3bc620f Binary files /dev/null and b/apps/api/public/uploads/线缆支架_1779693690954_001.jpg differ diff --git a/apps/api/public/uploads/线缆支架_1779779926892_001.jpg b/apps/api/public/uploads/线缆支架_1779779926892_001.jpg new file mode 100644 index 0000000..77ca8fc Binary files /dev/null and b/apps/api/public/uploads/线缆支架_1779779926892_001.jpg differ diff --git a/apps/api/public/uploads/线缆支架_1779780114438_001.jpg b/apps/api/public/uploads/线缆支架_1779780114438_001.jpg new file mode 100644 index 0000000..f385b33 Binary files /dev/null and b/apps/api/public/uploads/线缆支架_1779780114438_001.jpg differ diff --git a/apps/api/public/uploads/线缆支架_1779957998316_001.jpg b/apps/api/public/uploads/线缆支架_1779957998316_001.jpg new file mode 100644 index 0000000..f7fb38f Binary files /dev/null and b/apps/api/public/uploads/线缆支架_1779957998316_001.jpg differ diff --git a/apps/api/public/uploads/线缆支架_1782112806436_001.jpg b/apps/api/public/uploads/线缆支架_1782112806436_001.jpg new file mode 100644 index 0000000..f9d69c4 Binary files /dev/null and b/apps/api/public/uploads/线缆支架_1782112806436_001.jpg differ diff --git a/apps/api/public/uploads/螺纹钢_1780469964942_001.jpg b/apps/api/public/uploads/螺纹钢_1780469964942_001.jpg new file mode 100644 index 0000000..0f8cf8d Binary files /dev/null and b/apps/api/public/uploads/螺纹钢_1780469964942_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779693639318_001.jpg b/apps/api/public/uploads/配电箱_1779693639318_001.jpg new file mode 100644 index 0000000..76ee71c Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779693639318_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779693656256_001.jpg b/apps/api/public/uploads/配电箱_1779693656256_001.jpg new file mode 100644 index 0000000..040fb6d Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779693656256_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779779875011_001.jpg b/apps/api/public/uploads/配电箱_1779779875011_001.jpg new file mode 100644 index 0000000..b125663 Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779779875011_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779779893211_001.jpg b/apps/api/public/uploads/配电箱_1779779893211_001.jpg new file mode 100644 index 0000000..1f1990d Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779779893211_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779780059559_001.jpg b/apps/api/public/uploads/配电箱_1779780059559_001.jpg new file mode 100644 index 0000000..ed32a8f Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779780059559_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779780078914_001.jpg b/apps/api/public/uploads/配电箱_1779780078914_001.jpg new file mode 100644 index 0000000..e49559e Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779780078914_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779957937988_001.jpg b/apps/api/public/uploads/配电箱_1779957937988_001.jpg new file mode 100644 index 0000000..0ceb144 Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779957937988_001.jpg differ diff --git a/apps/api/public/uploads/配电箱_1779957957819_001.jpg b/apps/api/public/uploads/配电箱_1779957957819_001.jpg new file mode 100644 index 0000000..624b79b Binary files /dev/null and b/apps/api/public/uploads/配电箱_1779957957819_001.jpg differ diff --git a/apps/api/public/uploads/钢材_1780027918411_001.jpg b/apps/api/public/uploads/钢材_1780027918411_001.jpg new file mode 100644 index 0000000..813a6af Binary files /dev/null and b/apps/api/public/uploads/钢材_1780027918411_001.jpg differ diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts new file mode 100644 index 0000000..8527512 --- /dev/null +++ b/apps/api/src/admin/admin.controller.ts @@ -0,0 +1,95 @@ +import { Controller, Get, Post, Patch, Put, Delete, Body, Param, UseGuards } from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' +import { AdminService } from './admin.service' +import { AdminGuard } from './admin.guard' + +@UseGuards(AuthGuard('jwt'), AdminGuard) +@Controller('api/admin') +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + // ---- Tenants ---- + + @Get('tenants') + findAllTenants() { + return this.adminService.findAllTenants() + } + + @Get('tenants/:id') + findOneTenant(@Param('id') id: string) { + return this.adminService.findOneTenant(id) + } + + @Post('tenants') + createTenant(@Body() body: any) { + return this.adminService.createTenant(body) + } + + @Patch('tenants/:id') + updateTenant(@Param('id') id: string, @Body() body: any) { + return this.adminService.updateTenant(id, body) + } + + @Delete('tenants/:id') + deleteTenant(@Param('id') id: string) { + return this.adminService.deleteTenant(id) + } + + // ---- Users ---- + + @Get('users') + findAllUsers() { + return this.adminService.findAllUsers() + } + + @Post('users') + createUser(@Body('username') username: string, @Body('password') password: string, @Body('name') name: string, @Body('tenantId') tenantId: string) { + return this.adminService.createUser({ username, password, name, tenantId }) + } + + @Post('users/:id/reset-password') + resetPassword(@Param('id') id: string, @Body('password') password: string) { + return this.adminService.resetUserPassword(id, password) + } + + @Post('users/:id/toggle-active') + toggleActive(@Param('id') id: string) { + return this.adminService.toggleUserActive(id) + } + + // ---- Plan Config ---- + + @Get('plan-config') + getPlanConfig() { + return this.adminService.getPlanConfig() + } + + @Put('plan-config') + updatePlanConfig(@Body() body: Record) { + return this.adminService.updatePlanConfig(body) + } + + // ---- System Config ---- + + @Get('configs') + findAllConfigs() { + return this.adminService.findAllConfigs() + } + + @Post('configs') + upsertConfig(@Body('key') key: string, @Body('value') value: string, @Body('group') group: string, @Body('description') description?: string) { + return this.adminService.upsertConfig(key, value, group, description) + } + + @Delete('configs/:id') + deleteConfig(@Param('id') id: string) { + return this.adminService.deleteConfig(id) + } + + // ---- Stats ---- + + @Get('stats') + getStats() { + return this.adminService.getStats() + } +} diff --git a/apps/api/src/admin/admin.guard.ts b/apps/api/src/admin/admin.guard.ts new file mode 100644 index 0000000..d14a944 --- /dev/null +++ b/apps/api/src/admin/admin.guard.ts @@ -0,0 +1,13 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common' + +@Injectable() +export class AdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() + const user = request.user + if (!user || user.role !== 'admin') { + throw new ForbiddenException('仅管理员可操作') + } + return true + } +} diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts new file mode 100644 index 0000000..ebdc0b0 --- /dev/null +++ b/apps/api/src/admin/admin.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { AdminController } from './admin.controller' +import { AdminService } from './admin.service' + +@Module({ + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts new file mode 100644 index 0000000..e86f18b --- /dev/null +++ b/apps/api/src/admin/admin.service.ts @@ -0,0 +1,267 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' +import * as bcrypt from 'bcrypt' + +@Injectable() +export class AdminService { + constructor(private prisma: PrismaService) {} + + // ---- Tenant CRUD ---- + + async findAllTenants() { + const tenants = await this.prisma.tenant.findMany({ orderBy: { createdAt: 'desc' } }) + return tenants.map((t) => ({ + ...t, + _userCount: 0, + _daysUntilExpiry: Math.ceil((new Date(t.validUntil).getTime() - Date.now()) / 86400000), + })) + } + + async findOneTenant(id: string) { + const tenant = await this.prisma.tenant.findUnique({ where: { id }, include: { users: true } }) + if (!tenant) throw new NotFoundException('租户不存在') + return tenant + } + + async createTenant(data: { + companyName: string + contactName?: string + contactEmail?: string + plan: string + status: string + validFrom?: string + validUntil: string + platformType?: string + platformUrl?: string + platformApiKey?: string + features?: string + usageQuotas?: string + adminUsername?: string + adminPassword?: string + adminName?: string + }) { + const { adminUsername, adminPassword, adminName, ...tenantData } = data + + const tenant = await this.prisma.tenant.create({ + data: { + companyName: tenantData.companyName, + contactName: tenantData.contactName, + contactEmail: tenantData.contactEmail, + plan: tenantData.plan, + status: tenantData.status, + validFrom: tenantData.validFrom ? new Date(tenantData.validFrom) : new Date(), + validUntil: new Date(tenantData.validUntil), + platformType: tenantData.platformType, + platformUrl: tenantData.platformUrl, + platformApiKey: tenantData.platformApiKey, + features: tenantData.features || JSON.stringify({ inquiry: true, imageGen: true, product: true, comparison: true }), + usageQuotas: tenantData.usageQuotas || JSON.stringify({ monthlyLeads: 100, monthlyImages: 50, monthlyProducts: 100, monthlyComparisons: 50, maxUsers: 5 }), + }, + }) + + if (adminUsername && adminPassword) { + const existing = await this.prisma.user.findFirst({ + where: { OR: [{ username: adminUsername }, { email: `${adminUsername}@system.local` }] }, + }) + if (existing) throw new ConflictException('管理员用户名已存在') + const hashed = await bcrypt.hash(adminPassword, 10) + await this.prisma.user.create({ + data: { + username: adminUsername, + email: `${adminUsername}@system.local`, + password: hashed, + name: adminName || `${tenantData.companyName}管理员`, + role: 'user', + tenantId: tenant.id, + }, + }) + } + + return tenant + } + + async updateTenant(id: string, data: any) { + const existing = await this.prisma.tenant.findUnique({ where: { id } }) + if (!existing) throw new NotFoundException('租户不存在') + + const updateData: any = { ...data } + if (data.validFrom) updateData.validFrom = new Date(data.validFrom) + if (data.validUntil) updateData.validUntil = new Date(data.validUntil) + + return this.prisma.tenant.update({ where: { id }, data: updateData }) + } + + async deleteTenant(id: string) { + const existing = await this.prisma.tenant.findUnique({ where: { id } }) + if (!existing) throw new NotFoundException('租户不存在') + + await this.prisma.user.updateMany({ where: { tenantId: id }, data: { tenantId: null } }) + return this.prisma.tenant.delete({ where: { id } }) + } + + // ---- User Management ---- + + async findAllUsers() { + return this.prisma.user.findMany({ + orderBy: { createdAt: 'desc' }, + include: { tenant: true }, + }) + } + + async resetUserPassword(userId: string, newPassword: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }) + if (!user) throw new NotFoundException('用户不存在') + + const hashed = await bcrypt.hash(newPassword, 10) + return this.prisma.user.update({ where: { id: userId }, data: { password: hashed } }) + } + + async toggleUserActive(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }) + if (!user) throw new NotFoundException('用户不存在') + + return this.prisma.user.update({ where: { id: userId }, data: { isActive: !user.isActive } }) + } + + async createUser(data: { username: string; password: string; name?: string; tenantId?: string; role?: string }) { + const existing = await this.prisma.user.findFirst({ + where: { OR: [{ username: data.username }, { email: `${data.username}@system.local` }] }, + }) + if (existing) throw new ConflictException('用户名已被使用') + const hashed = await bcrypt.hash(data.password, 10) + const user = await this.prisma.user.create({ + data: { + username: data.username, + email: `${data.username}@system.local`, + password: hashed, + name: data.name, + role: data.role || 'user', + tenantId: data.tenantId, + }, + }) + const { password, ...result } = user + return result + } + + // ---- Plan Config ---- + + async getPlanConfig(): Promise> { + const row = await this.prisma.systemConfig.findUnique({ where: { key: 'plan_config' } }) + if (!row) { + return this.defaultPlanConfig() + } + try { + return JSON.parse(row.value) + } catch { + return this.defaultPlanConfig() + } + } + + private defaultPlanConfig() { + return { + basic: { + label: '基础版', price: 3000, description: '小型团队起步', + features: { inquiry: true, imageGen: false, product: true, comparison: false }, + quotas: { monthlyLeads: 100, monthlyImages: 0, monthlyProducts: 100, monthlyComparisons: 0, maxUsers: 5 }, + platformCount: 1, supportLevel: '社区支持', + }, + pro: { + label: '专业版', price: 6000, description: '中型企业', + features: { inquiry: true, imageGen: true, product: true, comparison: true }, + quotas: { monthlyLeads: 500, monthlyImages: 50, monthlyProducts: 500, monthlyComparisons: 100, maxUsers: 20 }, + platformCount: 3, supportLevel: '标准支持', + }, + enterprise: { + label: '旗舰版', price: 9000, description: '大型企业/多租户', + features: { inquiry: true, imageGen: true, product: true, comparison: true }, + quotas: { monthlyLeads: 99999, monthlyImages: 99999, monthlyProducts: 99999, monthlyComparisons: 99999, maxUsers: 99999 }, + platformCount: 999, supportLevel: '优先技术支持', + }, + } + } + + async updatePlanConfig(config: Record) { + await this.prisma.systemConfig.upsert({ + where: { key: 'plan_config' }, + update: { value: JSON.stringify(config), group: 'system', description: '订阅方案配置(JSON)' }, + create: { key: 'plan_config', value: JSON.stringify(config), group: 'system', description: '订阅方案配置(JSON)' }, + }) + return config + } + + // ---- System Config ---- + + async findAllConfigs() { + return this.prisma.systemConfig.findMany({ orderBy: [{ group: 'asc' }, { key: 'asc' }] }) + } + + async upsertConfig(key: string, value: string, group: string, description?: string) { + return this.prisma.systemConfig.upsert({ + where: { key }, + update: { value, group, description }, + create: { key, value, group, description }, + }) + } + + async deleteConfig(id: string) { + return this.prisma.systemConfig.delete({ where: { id } }) + } + + // ---- Platform Stats ---- + + async getStats() { + const [tenantCount, userCount, globalProductCount, leadCount, imageCount, inquiryCount] = + await Promise.all([ + this.prisma.tenant.count(), + this.prisma.user.count(), + this.prisma.product.count(), + this.prisma.lead.count(), + this.prisma.generatedImage.count(), + this.prisma.inquiry.count(), + ]) + + const tenants = await this.prisma.tenant.findMany({ + include: { users: true }, + }) + + const allProducts = await this.prisma.product.findMany() + + const perTenant = tenants.map((t) => { + const quotas = (() => { + try { return JSON.parse(t.usageQuotas || '{}') } catch { return {} } + })() + + // Match products to tenant by supplier name overlap + const short = t.companyName.replace(/有限公司|集团|股份|科技/g, '') + const tenantProducts = allProducts.filter((p) => p.supplier && short.includes(p.supplier)) + + return { + id: t.id, + companyName: t.companyName, + plan: t.plan, + status: t.status, + validUntil: t.validUntil, + userCount: t.users.length, + quotas: { + monthlyLeads: quotas.monthlyLeads ?? 0, + monthlyImages: quotas.monthlyImages ?? 0, + monthlyProducts: quotas.monthlyProducts ?? 0, + monthlyComparisons: quotas.monthlyComparisons ?? 0, + maxUsers: quotas.maxUsers ?? 0, + }, + usage: { + leads: 0, + images: 0, + products: tenantProducts.length, + comparisons: 0, + users: t.users.length, + }, + } + }) + + return { + overview: { tenantCount, userCount, productCount: globalProductCount, leadCount, imageCount, inquiryCount }, + perTenant, + } + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..344e9f0 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from './prisma/prisma.module' +import { InquiryModule } from './inquiry/inquiry.module' +import { ImageModule } from './image/image.module' +import { ProductModule } from './product/product.module' +import { ComparisonModule } from './comparison/comparison.module' +import { LeadModule } from './lead/lead.module' +import { RulesModule } from './rules/rules.module' +import { ReportsModule } from './reports/reports.module' +import { CrawlerModule } from './crawler/crawler.module' +import { AuditLogModule } from './audit-log/audit-log.module' +import { AuthModule } from './auth/auth.module' +import { UsersModule } from './users/users.module' +import { AdminModule } from './admin/admin.module' +import { IntegrationModule } from './integration/integration.module' +import { HealthController } from './common/health.controller' + +@Module({ + imports: [ + PrismaModule, + AuthModule, + UsersModule, + AdminModule, + InquiryModule, + ImageModule, + ProductModule, + ComparisonModule, + LeadModule, + RulesModule, + ReportsModule, + CrawlerModule, + AuditLogModule, + IntegrationModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/apps/api/src/audit-log/audit-log.controller.ts b/apps/api/src/audit-log/audit-log.controller.ts new file mode 100644 index 0000000..f4b389f --- /dev/null +++ b/apps/api/src/audit-log/audit-log.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get, Query } from '@nestjs/common' +import { AuditLogService } from './audit-log.service' + +@Controller('api/audit-logs') +export class AuditLogController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Get() + findAll(@Query('limit') limit?: string) { + return this.auditLogService.findAll(limit ? parseInt(limit, 10) : undefined) + } +} diff --git a/apps/api/src/audit-log/audit-log.module.ts b/apps/api/src/audit-log/audit-log.module.ts new file mode 100644 index 0000000..0691af8 --- /dev/null +++ b/apps/api/src/audit-log/audit-log.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from '../prisma/prisma.module' +import { AuditLogService } from './audit-log.service' +import { AuditLogController } from './audit-log.controller' + +@Module({ + imports: [PrismaModule], + controllers: [AuditLogController], + providers: [AuditLogService], +}) +export class AuditLogModule {} diff --git a/apps/api/src/audit-log/audit-log.service.ts b/apps/api/src/audit-log/audit-log.service.ts new file mode 100644 index 0000000..29c9a90 --- /dev/null +++ b/apps/api/src/audit-log/audit-log.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class AuditLogService { + constructor(private prisma: PrismaService) {} + + async findAll(limit?: number) { + return this.prisma.auditLog.findMany({ + orderBy: { createdAt: 'desc' }, + take: limit || 50, + }) + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..ea4a841 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' +import { AuthService } from './auth.service' + +@Controller('api/auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('register') + register(@Body('email') email: string, @Body('password') password: string, @Body('name') name?: string) { + return this.authService.register(email, password, name) + } + + @Post('login') + login(@Body('username') username: string, @Body('password') password: string) { + return this.authService.login(username, password) + } + + @Get('profile') + @UseGuards(AuthGuard('jwt')) + profile(@Req() req: any) { + return this.authService.profile(req.user.userId) + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..f2e656f --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +import { PassportModule } from '@nestjs/passport' +import { PrismaModule } from '../prisma/prisma.module' +import { AuthService } from './auth.service' +import { AuthController } from './auth.controller' +import { JwtStrategy } from './jwt.strategy' + +@Module({ + imports: [ + PrismaModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: process.env.JWT_SECRET || 'platform-assistant-secret-key-2026', + signOptions: { expiresIn: '7d' }, + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..51f7447 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,49 @@ +import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import * as bcrypt from 'bcrypt' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class AuthService { + constructor( + private prisma: PrismaService, + private jwtService: JwtService, + ) {} + + async register(email: string, password: string, name?: string) { + const existing = await this.prisma.user.findUnique({ where: { email } }) + if (existing) throw new ConflictException('邮箱已注册') + + const hashed = await bcrypt.hash(password, 10) + const user = await this.prisma.user.create({ + data: { email, password: hashed, name: name || email.split('@')[0] }, + }) + + const token = this.jwtService.sign({ userId: user.id, email: user.email, role: user.role }) + return { access_token: token, user: { id: user.id, username: user.username, email: user.email, name: user.name, role: user.role } } + } + + async login(username: string, password: string) { + const user = await this.prisma.user.findFirst({ + where: { + OR: [ + { email: username }, + { username: username }, + ], + }, + }) + if (!user) throw new UnauthorizedException('用户名或密码错误') + + const valid = await bcrypt.compare(password, user.password) + if (!valid) throw new UnauthorizedException('用户名或密码错误') + + const token = this.jwtService.sign({ userId: user.id, email: user.email, role: user.role }) + return { access_token: token, user: { id: user.id, username: user.username, email: user.email, name: user.name, role: user.role } } + } + + async profile(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }) + if (!user) throw new UnauthorizedException('用户不存在') + return { id: user.id, username: user.username, email: user.email, name: user.name, role: user.role, phone: user.phone } + } +} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..8ee0afb --- /dev/null +++ b/apps/api/src/auth/jwt.strategy.ts @@ -0,0 +1,20 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common' +import { PassportStrategy } from '@nestjs/passport' +import { ExtractJwt, Strategy } from 'passport-jwt' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private prisma: PrismaService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET || 'platform-assistant-secret-key-2026', + }) + } + + async validate(payload: { userId: string; email: string; role: string }) { + const user = await this.prisma.user.findUnique({ where: { id: payload.userId } }) + if (!user || !user.isActive) throw new UnauthorizedException('用户不存在或已禁用') + return { userId: user.id, email: user.email, role: user.role } + } +} diff --git a/apps/api/src/common/health.controller.ts b/apps/api/src/common/health.controller.ts new file mode 100644 index 0000000..6b74a50 --- /dev/null +++ b/apps/api/src/common/health.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' + +@Controller('api/health') +export class HealthController { + constructor(private prisma: PrismaService) {} + + @Get() + async getHealth() { + const inquiryCount = await this.prisma.inquiry.count() + const productCount = await this.prisma.product.count() + const imageCount = await this.prisma.generatedImage.count() + const leadCount = await this.prisma.lead.count() + const ruleCount = await this.prisma.rule.count() + const reportCount = await this.prisma.report.count() + const crawlerCount = await this.prisma.crawlerConfig.count() + + return [ + { name: '线索管理', adapterCount: `${leadCount} 条线索`, dailyVolume: `${inquiryCount} 条询价`, status: 'normal' as const }, + { name: '规则中心', adapterCount: `${ruleCount} 条规则`, dailyVolume: '—', status: 'normal' as const }, + { name: '报告中心', adapterCount: `${reportCount} 份报告`, dailyVolume: '—', status: 'normal' as const }, + { name: '系统监控', adapterCount: `${crawlerCount} 个采集器`, dailyVolume: `${crawlerCount * 10}+ 次/日`, status: 'normal' as const }, + { name: '商品生图助手', adapterCount: 'MiniMax AI', dailyVolume: `${imageCount} 张`, status: 'normal' as const }, + { name: '商品上架助手', adapterCount: '模拟渠道', dailyVolume: `${productCount} SKU`, status: 'normal' as const }, + { name: '智能比价助手', adapterCount: '多供应商', dailyVolume: `${Math.max(productCount - 1, 0)} 次`, status: 'normal' as const }, + ] + } +} diff --git a/apps/api/src/comparison/comparison.controller.ts b/apps/api/src/comparison/comparison.controller.ts new file mode 100644 index 0000000..12fc329 --- /dev/null +++ b/apps/api/src/comparison/comparison.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Post, Body, UseInterceptors, UploadedFile } from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ComparisonService } from './comparison.service' + +@Controller('api/comparison') +export class ComparisonController { + constructor(private readonly comparisonService: ComparisonService) {} + + @Get() + getData() { + return this.comparisonService.getComparisonData() + } + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + uploadQuote( + @UploadedFile() file: Express.Multer.File, + @Body('supplier') supplierName: string, + ) { + return this.comparisonService.uploadQuote(file, supplierName || '未命名供应商') + } + + @Post('suggestion') + getSuggestion() { + return this.comparisonService.getSuggestion() + } +} diff --git a/apps/api/src/comparison/comparison.module.ts b/apps/api/src/comparison/comparison.module.ts new file mode 100644 index 0000000..d02f3e5 --- /dev/null +++ b/apps/api/src/comparison/comparison.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ComparisonController } from './comparison.controller' +import { ComparisonService } from './comparison.service' + +@Module({ + controllers: [ComparisonController], + providers: [ComparisonService], + exports: [ComparisonService], +}) +export class ComparisonModule {} diff --git a/apps/api/src/comparison/comparison.service.ts b/apps/api/src/comparison/comparison.service.ts new file mode 100644 index 0000000..1ec030a --- /dev/null +++ b/apps/api/src/comparison/comparison.service.ts @@ -0,0 +1,315 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' +import * as fs from 'fs/promises' +import * as fsSync from 'fs' +import * as path from 'path' +import { execSync } from 'child_process' + +@Injectable() +export class ComparisonService { + constructor(private prisma: PrismaService) {} + + async getComparisonData() { + const products = await this.prisma.product.findMany({ + orderBy: { createdAt: 'desc' }, + }) + + const grouped = new Map() + for (const p of products) { + const key = p.name.trim().toLowerCase() + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key)!.push(p) + } + + const results: any[] = [] + for (const [, items] of grouped) { + // Deduplicate by supplier name: keep lowest price per supplier + const dedupMap = new Map() + for (const i of items) { + const key = i.supplier || '' + if (!dedupMap.has(key) || i.price < dedupMap.get(key)!.price) { + dedupMap.set(key, i) + } + } + const deduped = Array.from(dedupMap.values()) + + const supplierItems = deduped.filter((i) => i.supplier) + const best = supplierItems.length > 0 + ? supplierItems.reduce((a, b) => (a.price < b.price ? a : b)) + : deduped[0] + const hasSupplier = supplierItems.length > 0 + + results.push({ + productName: items[0].name, + spec: items[0].spec, + category: items[0].category, + quantity: Math.max(...items.map((i: { quantity: number }) => i.quantity), 1), + suppliers: deduped.map((i) => ({ + id: i.id, + name: i.supplier || '', + price: i.price, + unit: i.unit, + spec: i.spec, + brand: i.brand, + minOrder: i.minOrder, + imageUrl: i.imageUrl, + })), + bestPrice: best.price, + bestSupplier: best.supplier || best.name, + hasSupplier, + savings: supplierItems.length > 1 + ? Math.round((supplierItems.reduce((sum: number, i: { price: number }) => sum + i.price, 0) / supplierItems.length - best.price) * 100) / 100 + : 0, + priceSpread: supplierItems.length > 1 + ? Math.round((Math.max(...supplierItems.map((i: { price: number }) => i.price)) - best.price) * 100) / 100 + : 0, + }) + } + return results.sort((a, b) => a.productName.localeCompare(b.productName, 'zh')) + } + + async uploadQuote(file: Express.Multer.File, supplierName: string) { + const tmpDir = path.resolve('/tmp') + const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') + const tmpFile = path.join(tmpDir, `quote_${Date.now()}_${safeName}`) + + try { + if (!file.buffer || file.buffer.length === 0) { + throw new HttpException('上传文件为空', HttpStatus.BAD_REQUEST) + } + await fs.writeFile(tmpFile, file.buffer) + + const scriptPath = path.resolve(__dirname, '../../../../../scripts/parse_products.py') + if (!fsSync.existsSync(scriptPath)) { + throw new HttpException('服务器解析脚本缺失', HttpStatus.INTERNAL_SERVER_ERROR) + } + + let output: string + try { + output = execSync(`python3 "${scriptPath}" "${tmpFile}"`, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }) + } catch (e: unknown) { + const err = e as { stderr?: string; stdout?: string; message?: string } + const detail = err.stderr || err.stdout || err.message || '未知错误' + throw new HttpException(`解析失败: ${detail}`, HttpStatus.BAD_REQUEST) + } + + const parsed = JSON.parse(output) + if (parsed.error) { + throw new HttpException(`解析失败: ${parsed.error}`, HttpStatus.BAD_REQUEST) + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new HttpException('未解析到有效数据', HttpStatus.BAD_REQUEST) + } + + let created = 0 + for (const p of parsed) { + await this.prisma.product.create({ + data: { + name: p.title || p.name || '未知商品', + spec: p.spec || '', + unit: p.unit || '个', + price: parseFloat(p.price) || 0, + quantity: p.quantity || 1, + category: p.category || '未分类', + brand: p.brand || '', + supplier: supplierName, + }, + }) + created++ + } + + return { count: created, supplier: supplierName } + } finally { + await fs.unlink(tmpFile).catch(() => {}) + } + } + + async getSuggestion() { + const data = await this.getComparisonData() + if (data.length === 0) return { totalItems: 0, splitPicks: [], supplierRanking: [], bestSingleSupplier: null, totalSplitCost: 0, savingsVsSingle: 0, savingsPctVsSingle: 0, recommendations: [] } + + const multiItems = data.filter(i => i.hasSupplier && i.suppliers.length > 0) + + // Per-SKU best pick (split by SKU) + const splitPicks: Array<{ + productName: string; spec: string; + bestSupplier: string; bestPrice: number; bestQuantity: number; bestTotal: number; + secondPrice: number; secondQuantity: number; secondTotal: number; + savings: number; savingsPercent: number; + allSuppliers: Array<{ name: string; price: number; quantity: number; total: number }> + }> = [] + let splitTotalCost = 0 + const supplierSplitCounts = new Map() + + for (const item of multiItems) { + const qty = item.quantity || 1 + const sorted = [...item.suppliers] + .filter(s => s.name) + .map(s => ({ ...s, total: Math.round(s.price * qty * 100) / 100 })) + .sort((a, b) => a.total - b.total) + if (sorted.length === 0) continue + const best = sorted[0] + const second = sorted[1] + const saving = second ? Math.round((second.total - best.total) * 100) / 100 : 0 + const pct = second ? Math.round(((second.total - best.total) / second.total) * 100) : 0 + + splitTotalCost += best.total + supplierSplitCounts.set(best.name, (supplierSplitCounts.get(best.name) || 0) + 1) + + splitPicks.push({ + productName: item.productName, + spec: item.spec, + bestSupplier: best.name, + bestPrice: best.price, + bestQuantity: qty, + bestTotal: best.total, + secondPrice: second ? second.price : best.price, + secondQuantity: second ? qty : 0, + secondTotal: second ? second.total : best.total, + savings: saving, + savingsPercent: pct, + allSuppliers: sorted.map(s => ({ name: s.name, price: s.price, quantity: qty, total: s.total })), + }) + } + + // Single-supplier analysis: compare total cost per supplier across all multi-supplier SKUs + const supplierTotals = new Map() + for (const item of multiItems) { + const qty = item.quantity || 1 + for (const s of item.suppliers) { + if (!s.name) continue + const cur = supplierTotals.get(s.name) || { total: 0, skuCount: 0 } + cur.total += Math.round(s.price * qty * 100) / 100 + cur.skuCount++ + supplierTotals.set(s.name, cur) + } + } + + const supplierRanking = Array.from(supplierTotals.entries()) + .map(([name, info]) => ({ name, total: Math.round(info.total * 100) / 100, skuCount: info.skuCount })) + .sort((a, b) => a.total - b.total) + + const bestSingleSupplier = supplierRanking[0] + const bestSingleTotal = bestSingleSupplier?.total || 0 + const savingsVsSingle = Math.round((bestSingleTotal - splitTotalCost) * 100) / 100 + const savingsPctVsSingle = bestSingleTotal > 0 + ? Math.round(((bestSingleTotal - splitTotalCost) / bestSingleTotal) * 1000) / 10 + : 0 + + // Build recommendations + const recommendations: string[] = [] + if (multiItems.length > 0) { + recommendations.push(`本次比价共涉及 ${multiItems.length} 个SKU,${supplierRanking.length} 家供应商参与报价。`) + + if (savingsVsSingle > 0) { + recommendations.push(`按SKU拆分下单比统一从「${bestSingleSupplier.name}」采购可再节省 ¥${savingsVsSingle.toFixed(2)}(降本 ${savingsPctVsSingle}%)。`) + } + + // Which suppliers win most SKUs + const topSuppliers = Array.from(supplierSplitCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + if (topSuppliers.length > 0) { + const desc = topSuppliers.map(([name, count]) => `${name}(${count}个SKU)`).join('、') + recommendations.push(`拆分方案中,${desc} 在各SKU上具有价格优势。`) + } + + // Find biggest savings SKU + const biggestSaving = [...splitPicks].sort((a, b) => b.savings - a.savings)[0] + if (biggestSaving && biggestSaving.savings > 0) { + recommendations.push(`单品最大价差出现在「${biggestSaving.productName}」,最优供应商 ${biggestSaving.bestSupplier} 报价 ¥${biggestSaving.bestPrice.toFixed(2)}(${biggestSaving.bestQuantity}个),总价 ¥${biggestSaving.bestTotal.toFixed(2)},比其他供应商最低节省 ¥${biggestSaving.savings.toFixed(2)}(降本 ${biggestSaving.savingsPercent}%)。`) + } + + // Risk warning + if (multiItems.length > 10) { + recommendations.push('建议拆分前评估各供应商的交付能力与品质一致性,避免多供应商管理带来的协调成本。') + } else { + recommendations.push('建议优先选择拆分方案以最大化降本效果,同时关注各供应商的交期与售后支持。') + } + } + + // Compute optimal split for K=2,3,4 + const splitByCount: Record = {} + for (const K of [2, 3, 4]) { + splitByCount[K] = this.computeOptimalSplitForK(multiItems, K, bestSingleTotal) + } + + return { + splitPicks, + totalSplitCost: Math.round(splitTotalCost * 100) / 100, + supplierRanking, + bestSingleSupplier: bestSingleSupplier ? { name: bestSingleSupplier.name, total: bestSingleSupplier.total } : null, + savingsVsSingle, + savingsPctVsSingle, + totalItems: multiItems.length, + recommendations, + splitByCount, + } + } + + private combinations(arr: T[], K: number): T[][] { + if (K === 0) return [[]] + if (arr.length < K) return [] + const result: T[][] = [] + for (let i = 0; i <= arr.length - K; i++) { + for (const sub of this.combinations(arr.slice(i + 1), K - 1)) { + result.push([arr[i], ...sub]) + } + } + return result + } + + private computeOptimalSplitForK(items: any[], K: number, singleSupplierTotal: number) { + const allNames = [...new Set(items.flatMap((i: any) => i.suppliers.map((s: any) => s.name)))] as string[] + if (allNames.length < K) { + return { bestSuppliers: [], totalCost: 0, savingsVsSingle: 0, savingsPct: 0, assignments: [], supplierBreakdown: [] } + } + + let best = { totalCost: Infinity, selectedSuppliers: [] as string[], assignments: [] as any[] } + + for (const combo of this.combinations(allNames, K)) { + let totalCost = 0 + const assignments: Array<{ productName: string; spec: string; supplier: string; price: number; quantity: number; total: number }> = [] + + for (const item of items) { + const qty = item.quantity || 1 + const eligible = item.suppliers.filter((s: any) => combo.includes(s.name)) + if (eligible.length === 0) continue + const bestS = eligible.reduce((a: any, b: any) => a.price < b.price ? a : b) + const total = Math.round(bestS.price * qty * 100) / 100 + totalCost += total + assignments.push({ productName: item.productName, spec: item.spec, supplier: bestS.name, price: bestS.price, quantity: qty, total }) + } + + totalCost = Math.round(totalCost * 100) / 100 + if (totalCost < best.totalCost) { + best = { totalCost, selectedSuppliers: combo, assignments } + } + } + + // Build supplier breakdown + const breakdown = new Map() + for (const a of best.assignments) { + const cur = breakdown.get(a.supplier) || { count: 0, total: 0 } + cur.count++ + cur.total += a.total + breakdown.set(a.supplier, cur) + } + + // 只返回实际有分配的供应商(排除组合中未使用到的供应商) + const usedSuppliers = Array.from(breakdown.keys()) + + return { + bestSuppliers: usedSuppliers, + totalCost: best.totalCost, + savingsVsSingle: Math.round((singleSupplierTotal - best.totalCost) * 100) / 100, + savingsPct: singleSupplierTotal > 0 ? Math.round(((singleSupplierTotal - best.totalCost) / singleSupplierTotal) * 1000) / 10 : 0, + assignments: best.assignments, + supplierBreakdown: Array.from(breakdown.entries()).map(([name, info]) => ({ name, count: info.count, total: Math.round(info.total * 100) / 100 })), + } + } +} diff --git a/apps/api/src/crawler/crawler.controller.ts b/apps/api/src/crawler/crawler.controller.ts new file mode 100644 index 0000000..dead387 --- /dev/null +++ b/apps/api/src/crawler/crawler.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Put, Param, Body } from '@nestjs/common' +import { CrawlerService } from './crawler.service' + +@Controller('api/crawlers') +export class CrawlerController { + constructor(private readonly crawlerService: CrawlerService) {} + + @Get() + findAll() { + return this.crawlerService.findAll() + } + + @Post() + create(@Body() body: { name: string; targetUrl: string; schedule?: string; type?: string; sourceName?: string; scriptPath?: string; keywords?: string; categories?: string }) { + return this.crawlerService.create(body) + } + + @Put(':id') + update(@Param('id') id: string, @Body() body: { name?: string; targetUrl?: string; schedule?: string; type?: string; sourceName?: string; scriptPath?: string; keywords?: string; categories?: string; status?: string }) { + return this.crawlerService.update(id, body) + } + + @Post(':id/run') + run(@Param('id') id: string) { + return this.crawlerService.run(id) + } + + @Post(':id/pause') + pause(@Param('id') id: string) { + return this.crawlerService.pause(id) + } + + @Post(':id/resume') + resume(@Param('id') id: string) { + return this.crawlerService.resume(id) + } +} diff --git a/apps/api/src/crawler/crawler.module.ts b/apps/api/src/crawler/crawler.module.ts new file mode 100644 index 0000000..ea1197b --- /dev/null +++ b/apps/api/src/crawler/crawler.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { ScheduleModule } from '@nestjs/schedule' +import { PrismaModule } from '../prisma/prisma.module' +import { CrawlerService } from './crawler.service' +import { CrawlerController } from './crawler.controller' + +@Module({ + imports: [PrismaModule, ScheduleModule.forRoot()], + controllers: [CrawlerController], + providers: [CrawlerService], +}) +export class CrawlerModule {} diff --git a/apps/api/src/crawler/crawler.service.ts b/apps/api/src/crawler/crawler.service.ts new file mode 100644 index 0000000..1401e26 --- /dev/null +++ b/apps/api/src/crawler/crawler.service.ts @@ -0,0 +1,290 @@ +import { Injectable, HttpException, HttpStatus, NotFoundException, OnModuleInit, Logger } from '@nestjs/common' +import { SchedulerRegistry } from '@nestjs/schedule' +import { CronJob } from 'cron' +import { execSync } from 'child_process' +import * as path from 'path' +import * as fs from 'fs' +import { PrismaService } from '../prisma/prisma.service' + +const SCRIPTS_DIR = path.resolve(__dirname, '../../../../../scripts') +const TENDER_FETCHER_DIR = path.join(SCRIPTS_DIR, 'tender-fetcher') + +interface FetchResult { + source: string + title: string + organization: string + publishTime: string + deadline: string + noticeId: string + detailUrl: string + amount?: string +} + +@Injectable() +export class CrawlerService implements OnModuleInit { + private readonly logger = new Logger(CrawlerService.name) + + constructor( + private prisma: PrismaService, + private schedulerRegistry: SchedulerRegistry, + ) {} + + async onModuleInit() { + const configs = await this.prisma.crawlerConfig.findMany({ + where: { status: 'active', schedule: { not: null } }, + }) + for (const c of configs) { + if (c.schedule) { + this.registerCronJob(c.id, c.schedule) + } + } + this.logger.log(`已注册 ${configs.length} 个定时采集任务`) + } + + private registerCronJob(id: string, cronExpr: string) { + if (this.schedulerRegistry.doesExist('cron', id)) { + this.schedulerRegistry.deleteCronJob(id) + } + const job = new CronJob(cronExpr, async () => { + this.logger.log(`定时触发采集器 ${id}`) + try { + await this.run(id) + } catch (e: unknown) { + this.logger.error(`定时采集器 ${id} 执行失败: ${(e as Error).message}`) + } + }) + this.schedulerRegistry.addCronJob(id, job) + job.start() + this.logger.log(`采集器 ${id} 定时任务已启动: ${cronExpr}`) + } + + private unregisterCronJob(id: string) { + if (this.schedulerRegistry.doesExist('cron', id)) { + this.schedulerRegistry.deleteCronJob(id) + this.logger.log(`采集器 ${id} 定时任务已移除`) + } + } + + async findAll() { + return this.prisma.crawlerConfig.findMany({ orderBy: { createdAt: 'desc' } }) + } + + async update(id: string, data: { name?: string; targetUrl?: string; schedule?: string; type?: string; sourceName?: string; scriptPath?: string; keywords?: string; categories?: string; status?: string }) { + const config = await this.prisma.crawlerConfig.findUnique({ where: { id } }) + if (!config) throw new NotFoundException('爬虫配置不存在') + + const finalStatus = data.status !== undefined ? data.status : config.status + const finalSchedule = data.schedule !== undefined ? data.schedule : config.schedule + + this.unregisterCronJob(id) + if (finalStatus === 'active' && finalSchedule) { + this.registerCronJob(id, finalSchedule) + } + + return this.prisma.crawlerConfig.update({ where: { id }, data }) + } + + async create(data: { name: string; targetUrl: string; schedule?: string; type?: string; sourceName?: string; scriptPath?: string; keywords?: string; categories?: string }) { + return this.prisma.crawlerConfig.create({ + data: { + name: data.name, + targetUrl: data.targetUrl, + schedule: data.schedule, + type: data.type || 'bid', + sourceName: data.sourceName || null, + scriptPath: data.scriptPath || null, + keywords: data.keywords || null, + categories: data.categories || null, + status: 'paused', + }, + }) + } + + async run(id: string) { + const config = await this.prisma.crawlerConfig.findUnique({ where: { id } }) + if (!config) throw new HttpException('爬虫配置不存在', HttpStatus.NOT_FOUND) + if (config.status === 'paused') throw new HttpException('爬虫已暂停', HttpStatus.BAD_REQUEST) + + try { + let results: FetchResult[] = [] + + if (config.type === 'bid_powerchina') { + results = await this.runPowerChina() + } else { + results = await this.runGenericScript(config) + } + + let successCount = 0 + for (const r of results) { + if (!r.noticeId) continue + try { + await this.prisma.lead.upsert({ + where: { + source_noticeId: { source: r.source, noticeId: r.noticeId }, + }, + update: { + title: r.title, + organization: r.organization, + deadline: r.deadline, + detailUrl: r.detailUrl, + publishTime: r.publishTime || null, + ...(r.amount ? { amount: r.amount } : {}), + summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`, + }, + create: { + title: r.title, + organization: r.organization, + source: r.source, + noticeId: r.noticeId, + detailUrl: r.detailUrl, + deadline: r.deadline, + publishTime: r.publishTime || null, + amount: r.amount || '待确认', + status: '待复核', + level: 'C', + category: '', + region: '', + summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`, + }, + }) + successCount++ + } catch { + // 其他异常跳过 + } + } + + return this.prisma.crawlerConfig.update({ + where: { id }, + data: { + lastRunAt: new Date(), + totalRuns: { increment: 1 }, + successRuns: config.successRuns + 1, + }, + }) + } catch (e: unknown) { + const err = e as { stderr?: string; stdout?: string; message?: string } + await this.prisma.crawlerConfig.update({ + where: { id }, + data: { failedRuns: { increment: 1 } }, + }) + const detail = err.stderr || err.stdout || err.message || '未知错误' + throw new HttpException(`抓取失败: ${detail}`, HttpStatus.INTERNAL_SERVER_ERROR) + } + } + + private async runPowerChina(): Promise { + const scriptPath = path.join(TENDER_FETCHER_DIR, 'fetch-v1.2.js') + if (!fs.existsSync(scriptPath)) { + throw new Error('中电建抓取脚本不存在') + } + + execSync(`node "${scriptPath}"`, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + timeout: 180000, + }) + + const today = new Date().toISOString().split('T')[0] + const statePath = path.join( + process.env.HOME || '', + 'desktop/招标公告', + today, + 'downloaded.json', + ) + + if (!fs.existsSync(statePath)) return [] + + const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')) + const downloaded = state.downloaded || {} + const results: FetchResult[] = [] + + for (const [key, v] of Object.entries(downloaded)) { + const info = v as { title?: string; skipped?: boolean; failed?: boolean; id?: string; deadline?: string } + if (info.skipped || info.failed) continue + const [systemId] = key.split('_') + const realId = info.id || systemId + results.push({ + source: '中电建', + title: info.title || '', + organization: '中电建', + publishTime: state.lastRun || '', + deadline: info.deadline || '', + noticeId: systemId, + detailUrl: `https://bid.powerchina.cn/notice/detail?id=${realId}`, + }) + } + + return results + } + + private async runGenericScript(config: { type: string; sourceName?: string | null; scriptPath?: string | null }): Promise { + const slug = config.type.replace(/^bid_/, '') + const scriptFile = config.scriptPath || `fetch-${slug}.py` + const scriptPath = path.join(TENDER_FETCHER_DIR, scriptFile) + const source = config.sourceName || slug + const jsonFile = `${slug}_downloaded.json` + + if (!fs.existsSync(scriptPath)) { + throw new Error(`抓取脚本不存在: ${scriptFile}`) + } + + const isJs = scriptFile.endsWith('.js') + const cmd = isJs ? `node "${scriptPath}"` : `python3 "${scriptPath}"` + execSync(cmd, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + timeout: 180000, + }) + + const today = new Date().toISOString().split('T')[0] + const statePath = path.join( + process.env.HOME || '', + 'desktop/招标公告', + today, + jsonFile, + ) + + if (!fs.existsSync(statePath)) return [] + + const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')) + const results: FetchResult[] = [] + + for (const [noticeId, v] of Object.entries(state)) { + const info = v as { title?: string; org?: string; publish_time?: string; detail_url?: string; id?: string; deadline?: string; amount?: string } + if (noticeId === 'lastRun') continue + const leadAmount = info.amount || '' + results.push({ + source, + title: info.title || '', + organization: info.org || source, + publishTime: info.publish_time || '', + deadline: info.deadline || '', + noticeId: info.id || noticeId, + detailUrl: info.detail_url || '', + amount: leadAmount, + }) + } + + return results + } + + async pause(id: string) { + this.unregisterCronJob(id) + return this.prisma.crawlerConfig.update({ + where: { id }, + data: { status: 'paused' }, + }) + } + + async resume(id: string) { + const config = await this.prisma.crawlerConfig.findUnique({ where: { id } }) + if (!config) throw new NotFoundException('爬虫配置不存在') + if (config.schedule) { + this.registerCronJob(id, config.schedule) + } + return this.prisma.crawlerConfig.update({ + where: { id }, + data: { status: 'active' }, + }) + } +} diff --git a/apps/api/src/image/image.controller.ts b/apps/api/src/image/image.controller.ts new file mode 100644 index 0000000..ae72eab --- /dev/null +++ b/apps/api/src/image/image.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Post, Body, Param, UseInterceptors, UploadedFile } from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ImageService } from './image.service' + +@Controller('api/images') +export class ImageController { + constructor(private readonly imageService: ImageService) {} + + @Get() + findAll() { + return this.imageService.findAll() + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.imageService.findOne(id) + } + + @Post('generate') + generate(@Body() body: { productName: string; style?: string }) { + return this.imageService.generate(body.productName, body.style) + } + + @Post(':id/approve') + approve(@Param('id') id: string) { + return this.imageService.approve(id) + } + + @Post(':id/regenerate') + regenerate(@Param('id') id: string) { + return this.imageService.regenerate(id) + } + + @Post('parse') + @UseInterceptors(FileInterceptor('file')) + parseExcel(@UploadedFile() file: Express.Multer.File) { + return this.imageService.parseExcel(file) + } +} diff --git a/apps/api/src/image/image.module.ts b/apps/api/src/image/image.module.ts new file mode 100644 index 0000000..503f64e --- /dev/null +++ b/apps/api/src/image/image.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ImageController } from './image.controller' +import { ImageService } from './image.service' + +@Module({ + controllers: [ImageController], + providers: [ImageService], + exports: [ImageService], +}) +export class ImageModule {} diff --git a/apps/api/src/image/image.service.ts b/apps/api/src/image/image.service.ts new file mode 100644 index 0000000..f133cd6 --- /dev/null +++ b/apps/api/src/image/image.service.ts @@ -0,0 +1,514 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' +import * as fs from 'fs/promises' +import * as fsSync from 'fs' +import * as path from 'path' +import { spawnSync, execSync } from 'child_process' + +const UPLOADS_DIR = path.resolve(__dirname, '../../../public/uploads') +const LIBRARY_DIR = path.resolve(__dirname, '../../../public/uploads/library') +const TEMP_DIR = path.resolve('/tmp/image_search') +const SCRIPTS_DIR = path.resolve(__dirname, '../../../../../scripts') + +const STYLE_PROMPTS: Record = { + white: '中国建材工业品电商白底主图,纯白背景,专业产品摄影,真实拍摄质感,高清细节,国内建材五金工业品风格,禁止外国文字英文水印人物场景', + scene: '中国建材工业品使用场景图,工地厂房仓库实景,国内工程项目环境,真实拍摄,禁止外国文字英文水印', + realistic: '中国建材工业品实物拍摄,自然光线,高清产品细节,国内供应商供货实拍风格,禁止外国文字英文水印卡通插画', + minimal: '中国建材工业品简约产品图,纯色背景,专业产品摄影,高清细节展示,禁止外国文字英文水印卡通插画', +} + +const SIZE_MAP: Record = { + '1:1': '1:1', + '4:3': '4:3', + '3:4': '3:4', + '16:9': '16:9', +} + +@Injectable() +export class ImageService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.generatedImage.findMany({ orderBy: { createdAt: 'desc' } }) + } + + async findOne(id: string) { + const image = await this.prisma.generatedImage.findUnique({ where: { id } }) + if (!image) throw new HttpException('图片不存在', HttpStatus.NOT_FOUND) + return image + } + + async generate(productName: string, style?: string) { + const image = await this.prisma.generatedImage.create({ + data: { productName, style, url: '', status: 'generating' }, + }) + + const finalStyle = style || 'white' + const aspectRatio = SIZE_MAP[finalStyle] || '1:1' + + // 三阶段流程(异步执行) + this.pipeline(image.id, productName, finalStyle, aspectRatio) + + return image + } + + private async pipeline(id: string, productName: string, style: string, ratio: string) { + try { + await fs.mkdir(UPLOADS_DIR, { recursive: true }) + await fs.mkdir(LIBRARY_DIR, { recursive: true }) + await fs.mkdir(TEMP_DIR, { recursive: true }) + + const safeName = productName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_') + const timestamp = Date.now() + const outputFilename = `${safeName}_${timestamp}.jpg` + const outputPath = path.join(LIBRARY_DIR, outputFilename) + + // 阶段1:本地图库匹配 + const localMatch = await this.findInLibrary(productName) + if (localMatch) { + await this.prisma.generatedImage.update({ + where: { id }, + data: { url: localMatch.url, status: 'done' }, + }) + return + } + + // 阶段2:百度图片搜索 + 处理 + const usedHashes = await this.getUsedHashes(productName) + const searched = await this.searchAndProcess(productName, style, ratio, outputPath, usedHashes) + if (searched.ok) { + const url = `http://localhost:4000/uploads/library/${outputFilename}` + await this.saveToLibrary(productName, outputFilename, 'baidu', style, ratio) + await this.prisma.generatedImage.update({ + where: { id }, + data: { url, status: 'done', sourceHash: searched.hash }, + }) + return + } + + // 阶段3:AI生图兜底 + await this.aiGenerate(id, productName, style, ratio, outputFilename) + } catch (e) { + await this.prisma.generatedImage.update({ + where: { id }, + data: { status: 'failed' }, + }) + } + } + + private async findInLibrary(productName: string) { + // 精确匹配 + const exact = await this.prisma.imageLibrary.findFirst({ + where: { productName }, + orderBy: { createdAt: 'desc' }, + }) + if (exact) { + const filePath = path.join(LIBRARY_DIR, exact.filePath) + if (fsSync.existsSync(filePath)) { + return { url: `http://localhost:4000/uploads/library/${exact.filePath}` } + } + } + + // 模糊匹配(提取关键词) + const keyword = this.extractKeyword(productName) + if (keyword) { + const fuzzy = await this.prisma.imageLibrary.findFirst({ + where: { keyword: { contains: keyword } }, + orderBy: { createdAt: 'desc' }, + }) + if (fuzzy) { + const filePath = path.join(LIBRARY_DIR, fuzzy.filePath) + if (fsSync.existsSync(filePath)) { + return { url: `http://localhost:4000/uploads/library/${fuzzy.filePath}` } + } + } + } + + return null + } + + private extractKeyword(productName: string): string { + // 提取核心关键词(如 "YJV-0.6/1kV 3×4 电力电缆" → "电力电缆") + const patterns = [ + /电力电缆/, /控制电缆/, /家装电线/, /防火门/, /配电箱/, + /钢材/, /钢管/, /螺纹钢/, /阀门/, /管材/, /水泥/, + /涂料/, /防水/, /电缆桥架/, /母线槽/, + ] + for (const p of patterns) { + if (p.test(productName)) return p.source + } + // 取最后两个中文词 + const match = productName.match(/[\u4e00-\u9fa5]{2,}$/) + return match ? match[0] : productName.slice(0, 6) + } + + private async searchAndProcess(productName: string, style: string, ratio: string, outputPath: string, excludeHashes: string[] = []): Promise<{ ok: boolean; hash?: string }> { + try { + const searchScript = path.join(SCRIPTS_DIR, 'search_image.py') + const processScript = path.join(SCRIPTS_DIR, 'process_image.py') + + if (!fsSync.existsSync(searchScript) || !fsSync.existsSync(processScript)) { + return { ok: false } + } + + const searchArgs = [searchScript, productName, TEMP_DIR, '--count', '5'] + for (const h of excludeHashes) { + searchArgs.push('--exclude-hash', h) + } + + const searchResult = spawnSync('python3', searchArgs, { encoding: 'utf-8', timeout: 30000 }) + + if (searchResult.status !== 0) return { ok: false } + + const searchData = JSON.parse(searchResult.stdout) + const downloaded: string[] = searchData.downloaded || [] + const hashes: string[] = searchData.hashes || [] + + if (downloaded.length === 0) return { ok: false } + + // 处理第一张图 + const inputPath = downloaded[0] + const inputHash = hashes[0] + const processResult = spawnSync('python3', [ + processScript, inputPath, outputPath, '--ratio', ratio, '--style', style, + ], { encoding: 'utf-8', timeout: 30000 }) + + if (processResult.status !== 0) return { ok: false } + + const processData = JSON.parse(processResult.stdout) + return { ok: processData.success === true, hash: inputHash } + } catch { + return { ok: false } + } + } + + private async aiGenerate(id: string, productName: string, style: string, ratio: string, outputFilename: string) { + const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS.white + const prefix = `${productName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_')}_${Date.now()}` + + const result = spawnSync('mmx', [ + 'image', 'generate', + '--prompt', `${stylePrompt}:${productName}`, + '--n', '1', + '--aspect-ratio', ratio, + '--out-dir', UPLOADS_DIR, + '--out-prefix', prefix, + '--output', 'json', + '--quiet', + ], { encoding: 'utf-8', timeout: 60000 }) + + if (result.status !== 0) { + await this.prisma.generatedImage.update({ + where: { id }, + data: { status: 'failed' }, + }) + return + } + + const files = fsSync.readdirSync(UPLOADS_DIR) + const genFile = files.filter(f => f.startsWith(prefix)).sort().reverse()[0] + + if (genFile) { + // 复制到图库 + const src = path.join(UPLOADS_DIR, genFile) + const dest = path.join(LIBRARY_DIR, outputFilename) + fsSync.copyFileSync(src, dest) + await this.saveToLibrary(productName, outputFilename, 'ai', style, '1:1') + + const url = `http://localhost:4000/uploads/library/${outputFilename}` + await this.prisma.generatedImage.update({ + where: { id }, + data: { url, status: 'done' }, + }) + } else { + await this.prisma.generatedImage.update({ + where: { id }, + data: { status: 'failed' }, + }) + } + } + + private async saveToLibrary(productName: string, filePath: string, source: string, style?: string, ratio?: string) { + const keyword = this.extractKeyword(productName) + await this.prisma.imageLibrary.create({ + data: { productName, keyword, filePath, source, style, ratio }, + }) + } + + async approve(id: string) { + const image = await this.prisma.generatedImage.findUnique({ where: { id } }) + if (!image || image.status !== 'done' || !image.url) { + throw new HttpException('图片不存在或未完成', HttpStatus.BAD_REQUEST) + } + + // 从 URL 提取文件名 + const filename = image.url.split('/').pop() + if (!filename) throw new HttpException('图片路径无效', HttpStatus.BAD_REQUEST) + + // 检查是否已在图库中(避免重复添加) + const existing = await this.prisma.imageLibrary.findFirst({ + where: { productName: image.productName, filePath: filename }, + }) + + if (!existing) { + const style = image.style || undefined + await this.saveToLibrary(image.productName, filename, 'approved', style, '1:1') + } + + return { success: true, message: '已加入图库' } + } + + async regenerate(id: string) { + const image = await this.prisma.generatedImage.findUnique({ where: { id } }) + if (!image) throw new HttpException('图片不存在', HttpStatus.NOT_FOUND) + + const finalStyle = image.style || 'white' + const aspectRatio = SIZE_MAP[finalStyle] || '1:1' + + // 标记当前图片为已替换(保留记录,前端可隐藏) + await this.prisma.generatedImage.update({ + where: { id }, + data: { status: 'replaced' }, + }) + + // 创建新的生图任务,跳过图库匹配 + const newImage = await this.prisma.generatedImage.create({ + data: { productName: image.productName, style: image.style, url: '', status: 'generating' }, + }) + + // 异步执行:跳过图库,直接搜索+处理 或 AI生图 + this.pipelineSkipLibrary(newImage.id, image.productName, finalStyle, aspectRatio, image.url, image.sourceHash ?? undefined) + + return newImage + } + + private async pipelineSkipLibrary(id: string, productName: string, style: string, ratio: string, excludeUrl: string, oldHash?: string) { + try { + await fs.mkdir(UPLOADS_DIR, { recursive: true }) + await fs.mkdir(LIBRARY_DIR, { recursive: true }) + await fs.mkdir(TEMP_DIR, { recursive: true }) + + const safeName = productName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_') + const timestamp = Date.now() + const outputFilename = `${safeName}_${timestamp}.jpg` + const outputPath = path.join(LIBRARY_DIR, outputFilename) + + // 收集所有已用 hash(本产品的历史图片) + const usedHashes = await this.getUsedHashes(productName) + if (oldHash) usedHashes.unshift(oldHash) + + // 阶段1:图片搜索(跳过图库和已用图) + console.log(`[重新生成] 开始搜索: ${productName}, 已排除 ${usedHashes.length} 个 hash`) + const searched = await this.searchAndProcessDifferent(productName, style, ratio, outputPath, usedHashes) + console.log(`[重新生成] 搜索结果: ${searched.ok}, hash=${searched.hash}`) + if (searched.ok) { + const url = `http://localhost:4000/uploads/library/${outputFilename}` + await this.prisma.generatedImage.update({ + where: { id }, + data: { url, status: 'done', sourceHash: searched.hash }, + }) + return + } + + // 阶段2:AI生图兜底(使用不同prompt) + console.log(`[重新生成] 搜索未命中,使用AI生图: ${productName}`) + await this.aiGenerateDifferent(id, productName, style, ratio, outputFilename) + } catch (e) { + console.error(`[重新生成] 失败:`, e) + await this.prisma.generatedImage.update({ + where: { id }, + data: { status: 'failed' }, + }) + } + } + + private async getUsedHashes(productName: string): Promise { + const records = await this.prisma.generatedImage.findMany({ + where: { productName, sourceHash: { not: null } }, + select: { sourceHash: true }, + orderBy: { createdAt: 'desc' }, + take: 20, + }) + return records.map(r => r.sourceHash!).filter(Boolean) + } + + private async searchAndProcessDifferent(productName: string, style: string, ratio: string, outputPath: string, excludeHashes: string[]): Promise<{ ok: boolean; hash?: string }> { + try { + const searchScript = path.join(SCRIPTS_DIR, 'search_image.py') + const processScript = path.join(SCRIPTS_DIR, 'process_image.py') + + if (!fsSync.existsSync(searchScript) || !fsSync.existsSync(processScript)) { + console.log('[重新生成] 脚本不存在') + return { ok: false } + } + + // 使用更具体的搜索词(去掉"白底 电商"这种泛化词) + const searchQueries = [ + `${productName} 建材 工业品`, + `${productName} 实物图`, + `${productName} 产品`, + ] + + const allUsedHashes = new Set(excludeHashes) + + for (let qi = 0; qi < searchQueries.length; qi++) { + const query = searchQueries[qi] + console.log(`[重新生成] 搜索: ${query}`) + + const searchArgs = [searchScript, query, TEMP_DIR, '--count', '5'] + for (const h of allUsedHashes) { + searchArgs.push('--exclude-hash', h) + } + + const searchResult = spawnSync('python3', searchArgs, { encoding: 'utf-8', timeout: 20000 }) + + if (searchResult.status !== 0) { + console.log(`[重新生成] 搜索失败: status=${searchResult.status}, stderr=${searchResult.stderr?.substring(0, 200)}`) + continue + } + + const searchData = JSON.parse(searchResult.stdout) + const downloaded: string[] = searchData.downloaded || [] + const hashes: string[] = searchData.hashes || [] + console.log(`[重新生成] 搜索到 ${downloaded.length} 张候选图`) + + if (downloaded.length === 0) continue + + // 逐张处理(script 已经按质量排好序,且已排除已用 hash) + for (let i = 0; i < downloaded.length; i++) { + const inputPath = downloaded[i] + const inputHash = hashes[i] + console.log(`[重新生成] 处理第 ${i} 张: ${inputPath}`) + const processResult = spawnSync('python3', [ + processScript, inputPath, outputPath, '--ratio', ratio, '--style', style, + ], { encoding: 'utf-8', timeout: 30000 }) + + if (processResult.status === 0) { + const processData = JSON.parse(processResult.stdout) + console.log(`[重新生成] 处理结果: ${processData.success}`) + if (processData.success === true) return { ok: true, hash: inputHash } + } else { + console.log(`[重新生成] 处理失败: status=${processResult.status}`) + } + } + } + + console.log('[重新生成] 所有搜索词都未找到合适图片') + return { ok: false } + } catch (e) { + console.error('[重新生成] 搜索异常:', e) + return { ok: false } + } + } + + private async aiGenerateDifferent(id: string, productName: string, style: string, ratio: string, outputFilename: string) { + const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS.white + // 使用更具体的产品描述词,让AI生成更贴近实物的图片 + const productDesc = this.getProductDescription(productName) + const prefix = `${productName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_')}_${Date.now()}` + const prompt = `${stylePrompt},${productDesc}:${productName}` + + console.log(`[重新生成] AI生图 prompt: ${prompt.substring(0, 100)}...`) + + const result = spawnSync('mmx', [ + 'image', 'generate', + '--prompt', prompt, + '--n', '1', + '--aspect-ratio', ratio, + '--out-dir', UPLOADS_DIR, + '--out-prefix', prefix, + '--output', 'json', + '--quiet', + ], { encoding: 'utf-8', timeout: 60000 }) + + console.log(`[重新生成] mmx status: ${result.status}, error: ${result.error}`) + + if (result.status !== 0) { + console.log(`[重新生成] mmx stderr: ${result.stderr?.substring(0, 300)}`) + await this.prisma.generatedImage.update({ + where: { id }, + data: { status: 'failed' }, + }) + return + } + + const files = fsSync.readdirSync(UPLOADS_DIR) + const genFile = files.filter(f => f.startsWith(prefix)).sort().reverse()[0] + + if (genFile) { + const src = path.join(UPLOADS_DIR, genFile) + const dest = path.join(LIBRARY_DIR, outputFilename) + fsSync.copyFileSync(src, dest) + + const url = `http://localhost:4000/uploads/library/${outputFilename}` + await this.prisma.generatedImage.update({ + where: { id }, + data: { url, status: 'done' }, + }) + console.log(`[重新生成] AI生图成功: ${url}`) + } else { + console.log('[重新生成] mmx 未生成文件') + await this.prisma.generatedImage.update({ + where: { id }, + data: { status: 'failed' }, + }) + } + } + + private getProductDescription(productName: string): string { + // 根据产品名称生成更具体的描述词 + const descMap: Record = { + '钢材': '工业用钢材,金属质感,银灰色光泽,整齐堆放的钢管/钢板', + '钢管': '工业用钢管,金属质感,银灰色光泽,整齐排列', + '螺纹钢': '建筑用螺纹钢,金属质感,带螺纹纹理', + '阀门': '工业阀门,金属质感,精密机械部件', + '管材': '工业用管材,PVC或金属材质,整齐排列', + '水泥': '水泥产品,灰色粉末或袋装水泥', + '涂料': '涂料产品,桶装涂料,色彩丰富', + '防水': '防水材料,卷材或涂料,专业建材', + '电缆': '电线电缆,卷盘或成捆,专业电工材料', + '电线': '电线电缆,卷盘或成捆,专业电工材料', + } + + for (const [key, desc] of Object.entries(descMap)) { + if (productName.includes(key)) return desc + } + + return '专业建材工业品,产品实物图,高清细节' + } + + async parseExcel(file: Express.Multer.File) { + const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') + const tmpDir = path.resolve('/tmp') + const tmpFile = path.join(tmpDir, `upload_${Date.now()}_${safeName}`) + + try { + if (!file.buffer || file.buffer.length === 0) { + throw new HttpException('上传文件为空', HttpStatus.BAD_REQUEST) + } + await fs.writeFile(tmpFile, file.buffer) + const scriptPath = path.resolve(__dirname, '../../../../../scripts/parse_products.py') + if (!fsSync.existsSync(scriptPath)) { + throw new HttpException('服务器解析脚本缺失', HttpStatus.INTERNAL_SERVER_ERROR) + } + let output: string + try { + output = execSync(`python3 "${scriptPath}" "${tmpFile}"`, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }) + } catch (e: unknown) { + const err = e as { stderr?: string; stdout?: string; message?: string } + const detail = err.stderr || err.stdout || err.message || '未知错误' + throw new HttpException(`解析失败: ${detail}`, HttpStatus.BAD_REQUEST) + } + const parsed = JSON.parse(output) + if (parsed.error) { + throw new HttpException(`解析失败: ${parsed.error}`, HttpStatus.BAD_REQUEST) + } + return parsed + } finally { + await fs.unlink(tmpFile).catch(() => {}) + } + } +} diff --git a/apps/api/src/inquiry/inquiry.controller.ts b/apps/api/src/inquiry/inquiry.controller.ts new file mode 100644 index 0000000..b42a460 --- /dev/null +++ b/apps/api/src/inquiry/inquiry.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Body, Param, Patch, Delete } from '@nestjs/common' +import { InquiryService } from './inquiry.service' + +@Controller('api/inquiries') +export class InquiryController { + constructor(private readonly inquiryService: InquiryService) {} + + @Get() + findAll() { + return this.inquiryService.findAll() + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.inquiryService.findOne(id) + } + + @Post() + create(@Body() body: any) { + return this.inquiryService.create(body) + } + + @Patch(':id/publish') + publish(@Param('id') id: string) { + return this.inquiryService.publish(id) + } + + @Post('batch-publish') + batchPublish(@Body() body: { ids: string[] }) { + return this.inquiryService.batchPublish(body.ids) + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.inquiryService.remove(id) + } +} diff --git a/apps/api/src/inquiry/inquiry.module.ts b/apps/api/src/inquiry/inquiry.module.ts new file mode 100644 index 0000000..a65a864 --- /dev/null +++ b/apps/api/src/inquiry/inquiry.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { InquiryController } from './inquiry.controller' +import { InquiryService } from './inquiry.service' + +@Module({ + controllers: [InquiryController], + providers: [InquiryService], + exports: [InquiryService], +}) +export class InquiryModule {} diff --git a/apps/api/src/inquiry/inquiry.service.ts b/apps/api/src/inquiry/inquiry.service.ts new file mode 100644 index 0000000..0ab9bd6 --- /dev/null +++ b/apps/api/src/inquiry/inquiry.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class InquiryService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.inquiry.findMany({ orderBy: { createdAt: 'desc' } }) + } + + async findOne(id: string) { + return this.prisma.inquiry.findUnique({ where: { id } }) + } + + async create(data: { + title: string + source: string + url: string + budget?: string + deadline?: string + buyer: string + category: string + }) { + return this.prisma.inquiry.create({ data }) + } + + async publish(id: string) { + return this.prisma.inquiry.update({ + where: { id }, + data: { status: 'published' }, + }) + } + + async batchPublish(ids: string[]) { + return this.prisma.inquiry.updateMany({ + where: { id: { in: ids } }, + data: { status: 'published' }, + }) + } + + async remove(id: string) { + return this.prisma.inquiry.delete({ where: { id } }) + } +} diff --git a/apps/api/src/integration/integration.controller.ts b/apps/api/src/integration/integration.controller.ts new file mode 100644 index 0000000..011ea25 --- /dev/null +++ b/apps/api/src/integration/integration.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + HttpCode, + HttpStatus, +} from '@nestjs/common' +import { IntegrationService } from './integration.service' + +@Controller('api/integrations') +export class IntegrationController { + constructor(private readonly integrationService: IntegrationService) {} + + @Post() + async create(@Body() body: { + name: string + type?: string + endpoint: string + appId?: string // B2B 运营后台"集成管理"申请到的 appId + apiKey?: string // appSecret (加密存储) + customerCode?: string + }) { + return this.integrationService.create(body) + } + + @Get() + async findAll() { + return this.integrationService.findAll() + } + + /** + * 测试连接 (不保存) - 多租户 APIKey + * 必须传 appId + appSecret + */ + @Post('test') + async testConnection(@Body() body: { + endpoint: string + appId: string + apiKey: string // appSecret + }) { + return this.integrationService.testConnection(body.endpoint, body.appId, body.apiKey) + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.integrationService.findOne(id) + } + + @Put(':id') + async update(@Param('id') id: string, @Body() body: { + name?: string + endpoint?: string + appId?: string + apiKey?: string + customerCode?: string + }) { + return this.integrationService.update(id, body) + } + + @Delete(':id') + @HttpCode(HttpStatus.OK) + async remove(@Param('id') id: string) { + return this.integrationService.remove(id) + } + + @Post(':id/connect') + async connect(@Param('id') id: string) { + return this.integrationService.connect(id) + } + + @Post(':id/disconnect') + @HttpCode(HttpStatus.OK) + async disconnect(@Param('id') id: string) { + await this.integrationService.disconnect(id) + return { success: true } + } + + @Post(':id/call-tool') + async callTool(@Param('id') id: string, @Body() body: { + toolName: string + args: any + }) { + return this.integrationService.callTool(id, body.toolName, body.args) + } +} diff --git a/apps/api/src/integration/integration.module.ts b/apps/api/src/integration/integration.module.ts new file mode 100644 index 0000000..62d0aae --- /dev/null +++ b/apps/api/src/integration/integration.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from '../prisma/prisma.module' +import { IntegrationService } from './integration.service' +import { IntegrationController } from './integration.controller' + +@Module({ + imports: [PrismaModule], + controllers: [IntegrationController], + providers: [IntegrationService], + exports: [IntegrationService], +}) +export class IntegrationModule {} diff --git a/apps/api/src/integration/integration.service.ts b/apps/api/src/integration/integration.service.ts new file mode 100644 index 0000000..783e157 --- /dev/null +++ b/apps/api/src/integration/integration.service.ts @@ -0,0 +1,457 @@ +import { Injectable, Logger, NotFoundException, OnModuleDestroy } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' + +interface MCPClientInstance { + client: Client + transport: any + connectedAt: Date +} + +@Injectable() +export class IntegrationService implements OnModuleDestroy { + private readonly logger = new Logger(IntegrationService.name) + private mcpClients: Map = new Map() + private readonly encryptKey: Buffer + + constructor(private prisma: PrismaService) { + const key = process.env.ENCRYPT_KEY + if (!key || key.length < 64) { + throw new Error('ENCRYPT_KEY 环境变量缺失或长度不足 (需要 64 hex = 32 bytes)') + } + this.encryptKey = Buffer.from(key, 'hex') + } + + private encrypt(plaintext: string): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', this.encryptKey, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return Buffer.concat([iv, tag, encrypted]).toString('base64') + } + + private decrypt(encoded: string): string { + const buf = Buffer.from(encoded, 'base64') + const iv = buf.subarray(0, 12) + const tag = buf.subarray(12, 28) + const ciphertext = buf.subarray(28) + const decipher = createDecipheriv('aes-256-gcm', this.encryptKey, iv) + decipher.setAuthTag(tag) + return decipher.update(ciphertext) + decipher.final('utf8') + } + + // 加密/解密 appSecret (对应 DB 字段 apiKey, 名字沿用历史) + private encryptApiKey(apiKey?: string | null): string | null { + if (!apiKey) return null + if (apiKey.length > 40 && /^[A-Za-z0-9+/=]+$/.test(apiKey)) return apiKey + return this.encrypt(apiKey) + } + + private decryptApiKey(apiKey?: string | null): string | null { + if (!apiKey) return null + try { + return this.decrypt(apiKey) + } catch { + return apiKey + } + } + + /** + * 构造 MCP transport 时使用的认证 header + * 多租户 APIKey: 用 x-app-id + x-app-secret 替代旧的 x-api-key + * x-customer-code 不再传(由 MCP 验证后从 DB 取并以 X-Customer-Code 头传给 Java 后端) + */ + private buildAuthHeaders(integration: { appId?: string | null; apiKey?: string | null; customerCode?: string | null }) { + const headers: Record = {} + if (integration.appId) headers['x-app-id'] = integration.appId + if (integration.apiKey) { + const secret = this.decryptApiKey(integration.apiKey) + if (secret) headers['x-app-secret'] = secret + } + return headers + } + + async onModuleDestroy() { + for (const [id, instance] of this.mcpClients) { + try { + await instance.client.close() + } catch (e) { + this.logger.warn(`关闭 MCP 连接 ${id} 失败: ${e.message}`) + } + } + this.mcpClients.clear() + } + + async create(data: { + name: string + type?: string + endpoint: string + appId?: string + apiKey?: string // appSecret + customerCode?: string + }) { + return this.prisma.integration.create({ + data: { + name: data.name, + type: data.type || 'mcp', + endpoint: data.endpoint, + appId: data.appId, + apiKey: this.encryptApiKey(data.apiKey), + customerCode: data.customerCode, + status: 'inactive', + }, + }) + } + + async findAll() { + const rows = await this.prisma.integration.findMany({ + orderBy: { createdAt: 'desc' }, + }) + return rows.map(r => ({ ...r, apiKey: this.decryptApiKey(r.apiKey) })) + } + + async findOne(id: string) { + const integration = await this.prisma.integration.findUnique({ + where: { id }, + }) + if (!integration) throw new NotFoundException('对接配置不存在') + return { ...integration, apiKey: this.decryptApiKey(integration.apiKey) } + } + + async update(id: string, data: { + name?: string + endpoint?: string + appId?: string + apiKey?: string + customerCode?: string + }) { + await this.findOne(id) + return this.prisma.integration.update({ + where: { id }, + data: { + ...data, + apiKey: data.apiKey !== undefined ? this.encryptApiKey(data.apiKey) : undefined, + }, + }) + } + + async remove(id: string) { + await this.findOne(id) + await this.disconnect(id) + return this.prisma.integration.delete({ + where: { id }, + }) + } + + /** + * 连接 MCP server (多租户 APIKey 模式) + */ + async connect(id: string): Promise<{ success: boolean; tools?: any[]; error?: string }> { + const integration = await this.findOne(id) + + if (!integration.appId || !integration.apiKey) { + const msg = '缺少 appId 或 appSecret,请先在 devbox B2B 运营后台"集成管理"申请 APIKey 后填入' + await this.prisma.integration.update({ + where: { id }, + data: { status: 'error', error: msg }, + }) + return { success: false, error: msg } + } + + try { + await this.disconnect(id) + + const client = new Client( + { name: 'platform-assistant', version: '1.0.0' }, + { capabilities: {} } + ) + + const transport = new StreamableHTTPClientTransport( + new URL(integration.endpoint), + { + requestInit: { + headers: this.buildAuthHeaders(integration), + }, + } + ) + + await client.connect(transport) + + const toolsResult = await client.listTools() + const tools = toolsResult.tools || [] + + // Persist companyUserId locally so lead publishToDirev can auto-fill enterpriseId/userId + let companyUserId: string | null = null + let customerCode: string | null = null + try { + const identityResult: any = await client.callTool({ + name: 'get_app_identity', + arguments: {}, + }) + const identityText = identityResult?.content?.[0]?.text + if (identityText) { + const identity = JSON.parse(identityText) + companyUserId = identity.companyUserId ? String(identity.companyUserId) : null + customerCode = identity.customerCode || null + } + } catch (e: any) { + this.logger.warn(`get_app_identity 调用失败 (非致命): ${e.message}`) + } + + this.mcpClients.set(id, { + client, + transport, + connectedAt: new Date(), + }) + + await this.prisma.integration.update({ + where: { id }, + data: { + status: 'active', + tools: JSON.stringify(tools), + lastConnected: new Date(), + error: null, + companyUserId, + customerCode: customerCode || integration.customerCode, + }, + }) + + this.logger.log(`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}`) + + return { success: true, tools } + } catch (e: any) { + this.logger.error(`MCP 连接失败: ${integration.name}`, e.stack) + await this.prisma.integration.update({ + where: { id }, + data: { status: 'error', error: e.message }, + }) + return { success: false, error: e.message } + } + } + + async disconnect(id: string) { + const instance = this.mcpClients.get(id) + if (instance) { + try { + await instance.client.close() + } catch (e) { + this.logger.warn(`关闭 MCP 连接 ${id} 失败: ${e.message}`) + } + this.mcpClients.delete(id) + } + } + + /** + * 测试连接 (不保存状态) - 多租户 APIKey + */ + async testConnection( + endpoint: string, + appId: string, + appSecret: string + ): Promise<{ success: boolean; tools?: any[]; error?: string }> { + let client: Client | null = null + let transport: any = null + + try { + client = new Client( + { name: 'platform-assistant-test', version: '1.0.0' }, + { capabilities: {} } + ) + + transport = new StreamableHTTPClientTransport( + new URL(endpoint), + { + requestInit: { + headers: { + 'x-app-id': appId, + 'x-app-secret': appSecret, + }, + }, + } + ) + + await client.connect(transport) + const toolsResult = await client.listTools() + const tools = toolsResult.tools || [] + + return { success: true, tools } + } catch (e: any) { + return { success: false, error: e.message } + } finally { + if (client) { + try { + await client.close() + } catch {} + } + } + } + + async callTool(integrationId: string, toolName: string, args: any): Promise { + const instance = this.mcpClients.get(integrationId) + if (!instance) { + throw new Error('MCP 未连接,请先连接') + } + + const result = await instance.client.callTool({ + name: toolName, + arguments: args, + }) + + return result + } + + /** + * 通用 MCP 调用封装 - 带重试 + 状态返回 + * + * @returns MCPResult - 包含 success/data/error/latencyMs/attempts + * + * 失败重试策略: + * - 第一次失败后等待 1s 重试 + * - 第二次失败后等待 2s 重试 + * - 第三次失败后放弃 + * - 如果未连接,自动尝试连接 + */ + async executeTool( + integrationId: string, + toolName: string, + args: any, + options: { retries?: number; retryDelayMs?: number; autoConnect?: boolean } = {}, + ): Promise> { + const { retries = 2, retryDelayMs = 1000, autoConnect = true } = options + const startTime = Date.now() + let attempts = 0 + let lastError = '' + + for (let i = 0; i <= retries; i++) { + attempts = i + 1 + try { + let instance = this.mcpClients.get(integrationId) + if (!instance && autoConnect) { + this.logger.log(`MCP 未连接 ${integrationId},尝试自动连接...`) + const connectResult = await this.connect(integrationId) + if (!connectResult.success) { + throw new Error(`自动连接失败: ${connectResult.error}`) + } + instance = this.mcpClients.get(integrationId) + } + if (!instance) { + throw new Error('MCP 客户端未初始化') + } + + const result = await instance.client.callTool({ name: toolName, arguments: args }) + const parsed = this.parseToolResult(result) + + return { + success: true, + data: parsed, + attempts, + latencyMs: Date.now() - startTime, + mode: 'mcp', + } + } catch (e: any) { + lastError = e.message || String(e) + this.logger.warn(`MCP tool 调用失败 (attempt ${attempts}/${retries + 1}): ${toolName} - ${lastError}`) + + if (i < retries) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs * (i + 1))) + } + } + } + + return { + success: false, + error: lastError, + attempts, + latencyMs: Date.now() - startTime, + mode: 'local', + } + } + + /** + * 解析 MCP tool 返回的 content 字段 + * MCP 协议格式: { content: [{ type: 'text', text: '...' }], isError?: boolean } + */ + private parseToolResult(result: any): T | null { + try { + if (!result) return null + if (result.isError) { + throw new Error(typeof result.content?.[0]?.text === 'string' ? result.content[0].text : 'Tool 调用返回错误') + } + if (result.content && Array.isArray(result.content) && result.content.length > 0) { + const item = result.content[0] + if (item.type === 'text') { + try { + return JSON.parse(item.text) as T + } catch { + return item.text as unknown as T + } + } + } + return result as T + } catch (e: any) { + this.logger.error(`解析 MCP tool 结果失败: ${e.message}`) + return null + } + } + + getClient(integrationId: string): Client | null { + const instance = this.mcpClients.get(integrationId) + return instance?.client || null + } + + async findActive() { + return this.prisma.integration.findMany({ + where: { status: 'active' }, + }) + } + + /** + * 找到 status=active 或 error 的 MCP 集成(error 状态也包含,因为 executeTool 会自动重连) + */ + async findUsableMCP(): Promise { + return this.prisma.integration.findMany({ + where: { + type: 'mcp', + status: { in: ['active', 'error'] }, + }, + }) + } + + /** + * 找到第一个指定条件的活跃集成(按类型/名称) + */ + async findActiveByPredicate(predicate: (i: any) => boolean): Promise { + const active = await this.findActive() + return active.find(predicate) || null + } + + /** + * 找到第一个活跃的 MCP 类型集成(优先匹配名称包含 B2B/询价/platform_network,否则取第一个) + */ + async findActiveMCP(): Promise { + const usable = await this.findUsableMCP() + if (usable.length === 0) return null + const priority = usable.find(i => + i.type === 'mcp' && ( + i.name.includes('B2B') || + i.name.includes('询价') || + i.name.includes('product_network') || + i.name.includes('direv') || + i.name.includes('巨变') || + i.name.includes('平台') + ) + ) + return priority || usable.find(i => i.type === 'mcp') || usable[0] + } +} + +export interface MCPResult { + success: boolean + data?: T | null + error?: string + attempts: number + latencyMs: number + mode: 'mcp' | 'local' +} diff --git a/apps/api/src/lead/lead.controller.ts b/apps/api/src/lead/lead.controller.ts new file mode 100644 index 0000000..8fa2633 --- /dev/null +++ b/apps/api/src/lead/lead.controller.ts @@ -0,0 +1,90 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common' +import { LeadService } from './lead.service' + +@Controller('api/leads') +export class LeadController { + constructor(private readonly leadService: LeadService) {} + + @Get() + findAll( + @Query('status') status?: string, + @Query('level') level?: string, + @Query('search') search?: string, + @Query('star') star?: string, + @Query('source') source?: string, + @Query('category') category?: string, + @Query('assignedTo') assignedTo?: string, + ) { + return this.leadService.findAll({ status, level, search, star, source, category, assignedTo }) + } + + @Get('stats') + getStats() { + return this.leadService.getStats() + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.leadService.findOne(id) + } + + @Post() + create(@Body() body: any) { + return this.leadService.create(body) + } + + @Put(':id') + update(@Param('id') id: string, @Body() body: any) { + return this.leadService.update(id, body) + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.leadService.remove(id) + } + + @Post(':id/to-rfq') + toRFQ(@Param('id') id: string, @Body('assignee') assignee: string, @Body('note') note: string) { + return this.leadService.toRFQ(id, assignee, note || '') + } + + @Post(':id/to-collab') + toCollab(@Param('id') id: string, @Body('assignee') assignee: string, @Body('note') note: string) { + return this.leadService.toCollab(id, assignee, note || '') + } + + @Post(':id/publish') + publish(@Param('id') id: string) { + return this.leadService.publishToDirev(id) + } + + @Post('batch-publish') + batchPublish(@Body('ids') ids: string[]) { + return this.leadService.batchPublish(ids) + } + + @Post(':id/follow-up') + addFollowUp(@Param('id') id: string, @Body('content') content: string, @Body('action') action: string) { + return this.leadService.addFollowUp(id, content, 'admin', action) + } + + @Post(':id/follow-up/:index/delete') + deleteFollowUp(@Param('id') id: string, @Param('index') index: string) { + return this.leadService.deleteFollowUp(id, parseInt(index, 10)) + } + + @Post(':id/level') + updateLevel(@Param('id') id: string, @Body('level') level: string) { + return this.leadService.updateLevel(id, level) + } + + @Post(':id/follow-intent') + followIntent(@Param('id') id: string) { + return this.leadService.followIntent(id) + } + + @Post('calculate-score') + calculateScore(@Body() body: any) { + return { score: this.leadService.calculateAIScore(body) } + } +} diff --git a/apps/api/src/lead/lead.module.ts b/apps/api/src/lead/lead.module.ts new file mode 100644 index 0000000..0d758c9 --- /dev/null +++ b/apps/api/src/lead/lead.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from '../prisma/prisma.module' +import { IntegrationModule } from '../integration/integration.module' +import { LeadService } from './lead.service' +import { LeadController } from './lead.controller' + +@Module({ + imports: [PrismaModule, IntegrationModule], + controllers: [LeadController], + providers: [LeadService], + exports: [LeadService], +}) +export class LeadModule {} diff --git a/apps/api/src/lead/lead.service.ts b/apps/api/src/lead/lead.service.ts new file mode 100644 index 0000000..4dd7d10 --- /dev/null +++ b/apps/api/src/lead/lead.service.ts @@ -0,0 +1,407 @@ +import { Injectable, NotFoundException, Logger, ServiceUnavailableException } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' +import { IntegrationService } from '../integration/integration.service' + +@Injectable() +export class LeadService { + private readonly logger = new Logger(LeadService.name) + + constructor( + private prisma: PrismaService, + private integrationService: IntegrationService, + ) {} + + async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string }) { + const where: any = {} + if (params?.status) where.status = params.status + if (params?.level) where.level = params.level + if (params?.search) { + where.OR = [ + { title: { contains: params.search } }, + { organization: { contains: params.search } }, + { region: { contains: params.search } }, + ] + } + if (params?.star) { + const starVal = parseInt(params.star, 10) + if (params.star === '2星及以下') where.star = { lte: 2 } + else if (!isNaN(starVal)) where.star = starVal + } + if (params?.source) where.source = params.source + if (params?.category) where.category = params.category + if (params?.assignedTo) { + where.assignedTo = params.assignedTo === '未分配' ? null : params.assignedTo + } + return this.prisma.lead.findMany({ + where, + orderBy: [ + { level: 'asc' }, + { createdAt: 'desc' }, + ], + }) + } + + async findOne(id: string) { + const lead = await this.prisma.lead.findUnique({ where: { id } }) + if (!lead) throw new NotFoundException('线索不存在') + return lead + } + + async create(data: any) { + if (!data.aiScore && data.title) { + data.aiScore = this.calculateAIScore(data) + } + return this.prisma.lead.create({ data }) + } + + async update(id: string, data: any) { + await this.findOne(id) + return this.prisma.lead.update({ where: { id }, data }) + } + + async remove(id: string) { + await this.findOne(id) + return this.prisma.lead.delete({ where: { id } }) + } + + async getStats() { + const leads = await this.prisma.lead.findMany() + return { + total: leads.length, + newToday: leads.filter(l => l.createdAt >= new Date(Date.now() - 86400000)).length, + byStatus: { + captured: leads.filter(l => l.status === '已抓取').length, + analyzed: leads.filter(l => l.status === '已研判').length, + pendingReview: leads.filter(l => l.status === '待复核').length, + pendingFollow: leads.filter(l => l.status === '待跟进').length, + following: leads.filter(l => l.status === '跟进中').length, + toRFQ: leads.filter(l => l.status === '已转询价').length, + toCollab: leads.filter(l => l.status === '已转协同').length, + success: leads.filter(l => l.status === '已成交').length, + invalid: leads.filter(l => l.status === '无效').length, + }, + byLevel: { + s: leads.filter(l => l.level === 'S').length, + a: leads.filter(l => l.level === 'A').length, + b: leads.filter(l => l.level === 'B').length, + c: leads.filter(l => l.level === 'C').length, + d: leads.filter(l => l.level === 'D').length, + }, + published: leads.filter(l => l.publish === '已发布').length, + notPublished: leads.filter(l => l.publish !== '已发布').length, + } + } + + async toRFQ(id: string, assignee: string, note: string) { + const lead = await this.findOne(id) + const now = new Date() + const logs = this.parseFollowLogs(lead.followLogs) + + const b2bIntegration = await this.integrationService.findActiveMCP() + + let rfqRemoteId: string | null = null + let mcpLogExtra = '' + let mcpStatus: 'mcp_success' | 'mcp_failed' | 'local_only' = 'local_only' + + if (b2bIntegration) { + const result = await this.integrationService.executeTool( + b2bIntegration.id, + 'publish_rfq', + { + title: lead.title || '平台助手转询价', + description: lead.summary || lead.title || '', + category_id: 1, + qty: 1, + unit: '件', + deadline: lead.deadline || '2026-12-31', + attachments: [], + }, + { retries: 2, retryDelayMs: 1000 }, + ) + + if (result.success && result.data) { + const data = result.data as any + rfqRemoteId = data.rfqId || data.id || null + mcpStatus = 'mcp_success' + mcpLogExtra = ` [MCP 成功,远程询价 ID=${rfqRemoteId},耗时 ${result.latencyMs}ms,尝试 ${result.attempts} 次]` + this.logger.log(`toRFQ MCP 成功: leadId=${id}, rfqId=${rfqRemoteId}`) + } else { + mcpStatus = 'mcp_failed' + mcpLogExtra = ` [MCP 失败,降级本地模式: ${result.error || '未知错误'},尝试 ${result.attempts} 次]` + this.logger.warn(`toRFQ MCP 失败: leadId=${id}, error=${result.error}`) + } + } else { + mcpLogExtra = ' [本地模式,未配置 B2B 集成]' + } + + logs.unshift({ + date: now.toISOString(), + content: `已转询价——询价单已创建,指派给 ${assignee},备注:${note}${mcpLogExtra}`, + operator: '系统', + mcpStatus, + }) + + return this.prisma.lead.update({ + where: { id }, + data: { + status: '已转询价', + assignedTo: assignee, + followLogs: JSON.stringify(logs), + ...(rfqRemoteId && { inquiryNo: rfqRemoteId }), + }, + }) + } + + async toCollab(id: string, assignee: string, note: string) { + const lead = await this.findOne(id) + const now = new Date() + const logs = this.parseFollowLogs(lead.followLogs) + + const b2bIntegration = await this.integrationService.findActiveMCP() + + let collabRemoteId: string | null = null + let mcpLogExtra = '' + let mcpStatus: 'mcp_success' | 'mcp_failed' | 'local_only' = 'local_only' + + if (b2bIntegration) { + const result = await this.integrationService.executeTool( + b2bIntegration.id, + 'create_collab_from_lead', + { + leadId: lead.id, + title: lead.title || '平台助手转协同', + description: lead.summary || lead.title || '', + organization: lead.organization || '', + region: lead.region || '', + assignee, + note, + }, + { retries: 2, retryDelayMs: 1000 }, + ) + + if (result.success && result.data) { + const data = result.data as any + collabRemoteId = data.collabId || data.id || null + mcpStatus = 'mcp_success' + mcpLogExtra = ` [MCP 成功,远程协同 ID=${collabRemoteId},耗时 ${result.latencyMs}ms,尝试 ${result.attempts} 次]` + this.logger.log(`toCollab MCP 成功: leadId=${id}, collabId=${collabRemoteId}`) + } else { + mcpStatus = 'mcp_failed' + mcpLogExtra = ` [MCP 失败,降级本地模式: ${result.error || '未知错误'},尝试 ${result.attempts} 次]` + this.logger.warn(`toCollab MCP 失败: leadId=${id}, error=${result.error}`) + } + } else { + mcpLogExtra = ' [本地模式,未配置 B2B 集成]' + } + + logs.unshift({ + date: now.toISOString(), + content: `已转协同——协同任务已创建,指派给 ${assignee},备注:${note}${mcpLogExtra}`, + operator: '系统', + mcpStatus, + }) + + return this.prisma.lead.update({ + where: { id }, + data: { + status: '已转协同', + assignedTo: assignee, + followLogs: JSON.stringify(logs), + ...(collabRemoteId && { collabId: collabRemoteId }), + }, + }) + } + + async publishToDirev(id: string) { + const lead = await this.findOne(id) + const now = new Date() + + const b2bIntegration = await this.integrationService.findActiveMCP() + + if (!b2bIntegration) { + throw new ServiceUnavailableException( + '未配置 B2B 集成(运营管理 → 系统配置 → MCP 对接配置)', + ) + } + + const publishArgs: any = { + title: lead.title || '平台助手发布询价', + description: lead.summary || lead.title || '', + qty: lead.quantity || 1, + } + + if (lead.attachments) { + try { + const parsedAttachments = JSON.parse(lead.attachments) + if (Array.isArray(parsedAttachments) && parsedAttachments.length > 0) { + publishArgs.attachments = parsedAttachments + } + } catch { + if (lead.detailUrl) publishArgs.attachments = [lead.detailUrl] + } + } else if (lead.detailUrl) { + publishArgs.attachments = [lead.detailUrl] + } + + if (lead.deadline) { + const deadlineStr = String(lead.deadline).slice(0, 10) + publishArgs.deadline = deadlineStr + const d = new Date(deadlineStr) + d.setDate(d.getDate() + 30) + publishArgs.deliveryDate = d.toISOString().slice(0, 10) + } + if (lead.regionDetail) publishArgs.shippingAddress = lead.regionDetail + else if (lead.region) publishArgs.shippingAddress = lead.region + + if (lead.contact) publishArgs.contactName = lead.contact + if (lead.phone) publishArgs.contactPhone = lead.phone + + publishArgs.inquiryParty = '郑州巨变网络科技有限公司' + publishArgs.freightFlag = '含运费' + publishArgs.procurementType = '定量采购' + publishArgs.invoiceType = '增值税专票' + publishArgs.paymentMethod = '货到付款' + + // Auto-fill enterprise identity from local Integration config + if (b2bIntegration.companyUserId) { + publishArgs.userId = b2bIntegration.companyUserId + publishArgs.enterpriseId = b2bIntegration.companyUserId + publishArgs.enterpriseName = `${b2bIntegration.name}公司` + } + + const result = await this.integrationService.executeTool( + b2bIntegration.id, + 'publish_rfq', + publishArgs, + { retries: 2, retryDelayMs: 1000 }, + ) + + if (!result.success || !result.data) { + const errorMsg = result.error || '未知错误' + this.logger.error( + `MCP 发布失败 (leadId=${id}, attempts=${result.attempts}): ${errorMsg}`, + ) + throw new ServiceUnavailableException( + `发布失败: B2B 平台未收到请求。原因: ${errorMsg}。请检查运营管理 → 系统配置 → MCP 对接配置(密钥/状态)`, + ) + } + + const data = result.data as any + // MCP 返回的 content[0].text 解析后是 {code, msg, data: {orderId, rfqId, status}} + // 业务对象嵌在 data.data 里(外层是 B2B 通用 Result 包装) + const payload = data.data || data + const rfqId = payload.rfqId || payload.id || null + if (!rfqId) { + throw new ServiceUnavailableException( + `发布失败: B2B 平台返回了响应但未包含询价 ID。响应: ${JSON.stringify(payload).slice(0, 200)}`, + ) + } + const inquiryNo = payload.orderId || rfqId + const direvUrl = `${b2bIntegration.endpoint}/inquiry/${rfqId}` + const mcpStatus = 'mcp_success' + const mcpLogExtra = ` [MCP 成功,远程询价 ID=${rfqId},耗时 ${result.latencyMs}ms,尝试 ${result.attempts} 次]` + + this.logger.log(`询价发布成功: leadId=${id}, rfqId=${rfqId}, orderId=${inquiryNo}`) + + const logs = this.parseFollowLogs(lead.followLogs) + logs.unshift({ + date: now.toISOString(), + content: `一键发布——已发布到 B2B 平台,询价 ID: ${rfqId}${mcpLogExtra}`, + operator: '系统', + mcpStatus, + }) + + return this.prisma.lead.update({ + where: { id }, + data: { + publish: '已发布', + publishedAt: now, + inquiryNo, + direvUrl, + followLogs: JSON.stringify(logs), + }, + }) + } + + async batchPublish(ids: string[]) { + const results: any[] = [] + for (const id of ids) { + try { + const lead = await this.publishToDirev(id) + results.push({ id, success: true, inquiryNo: lead.inquiryNo }) + } catch (e: any) { + results.push({ id, success: false, error: e.message }) + } + } + return results + } + + async addFollowUp(id: string, content: string, operator: string, action?: string) { + const lead = await this.findOne(id) + const logs = this.parseFollowLogs(lead.followLogs) + const logContent = action ? `${action}——${content}` : content + logs.unshift({ + date: new Date().toISOString(), + content: logContent, + operator: operator || '用户', + }) + const updateData: any = { followLogs: JSON.stringify(logs) } + if (lead.status === '待复核' || lead.status === '待跟进') { + updateData.status = '跟进中' + } + return this.prisma.lead.update({ where: { id }, data: updateData }) + } + + async deleteFollowUp(id: string, index: number) { + const lead = await this.findOne(id) + const logs = this.parseFollowLogs(lead.followLogs) + if (index >= 0 && index < logs.length) { + logs.splice(index, 1) + return this.prisma.lead.update({ + where: { id }, + data: { followLogs: JSON.stringify(logs) }, + }) + } + return lead + } + + async updateLevel(id: string, level: string) { + return this.update(id, { level }) + } + + async followIntent(id: string) { + const lead = await this.findOne(id) + if (lead.status !== '待复核') throw new Error('只有待复核状态的线索可以提交跟进意向') + const logs = this.parseFollowLogs(lead.followLogs) + logs.unshift({ + date: new Date().toISOString(), + content: '提交跟进意向——确认值得跟进,状态推进至待跟进', + operator: '系统', + }) + return this.prisma.lead.update({ + where: { id }, + data: { status: '待跟进', followLogs: JSON.stringify(logs) }, + }) + } + + calculateAIScore(data: any): number { + let score = 0 + if (data.level === 'S') score += 20 + else if (data.level === 'A') score += 10 + const prefTypes = ['水利', '南水北调', '引水', '河道治理', '电力', '输变电'] + if (data.summary && prefTypes.some(t => data.summary.includes(t))) score += 10 + const coreCategories = ['防水', '涂料', '钢材', '水泥', '电缆'] + if (data.category && coreCategories.some(c => data.category.includes(c))) score += 15 + if (data.amount && data.amount !== '待确认') score += 5 + return Math.min(score / 10, 10) + } + + private parseFollowLogs(logs: string | null): any[] { + if (!logs) return [] + try { + return JSON.parse(logs) + } catch { + return [] + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..0b2bd0c --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,23 @@ +import { NestFactory } from '@nestjs/core' +import { ValidationPipe } from '@nestjs/common' +import { AppModule } from './app.module' +import * as express from 'express' +import * as path from 'path' + +async function bootstrap() { + const app = await NestFactory.create(AppModule) + + app.enableCors({ + origin: (process.env.CORS_ORIGIN || 'http://localhost:3000,http://localhost:3001,https://direv.cloud') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + credentials: true, + }) + app.useGlobalPipes(new ValidationPipe({ transform: true })) + app.use('/uploads', express.static(path.resolve(__dirname, '../../public/uploads'))) + + await app.listen(process.env.PORT || 4000) + console.log(`API running on http://localhost:${process.env.PORT || 4000}`) +} +bootstrap() diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..4501415 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common' +import { PrismaService } from './prisma.service' + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..9e3d1aa --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect() + } + + async onModuleDestroy() { + await this.$disconnect() + } +} diff --git a/apps/api/src/product/product.controller.ts b/apps/api/src/product/product.controller.ts new file mode 100644 index 0000000..c70d17a --- /dev/null +++ b/apps/api/src/product/product.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Post, Body, Param, Patch, Delete, UseInterceptors, UploadedFile } from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ProductService } from './product.service' +import * as fs from 'fs/promises' +import * as path from 'path' + +const UPLOADS_DIR = path.resolve(__dirname, '../../../public/uploads') + +@Controller('api/products') +export class ProductController { + constructor(private readonly productService: ProductService) {} + + @Get() + findAll() { + return this.productService.findAll() + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.productService.findOne(id) + } + + @Post() + create(@Body() body: any) { + return this.productService.create(body) + } + + @Patch(':id') + update(@Param('id') id: string, @Body() body: any) { + return this.productService.update(id, body) + } + + @Patch(':id/publish') + publish(@Param('id') id: string) { + return this.productService.publish(id) + } + + @Post('batch-publish') + batchPublish(@Body() body: { ids: string[] }) { + return this.productService.batchPublish(body.ids) + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.productService.remove(id) + } + + @Post('upload-image') + @UseInterceptors(FileInterceptor('file')) + async uploadImage(@UploadedFile() file: Express.Multer.File) { + await fs.mkdir(UPLOADS_DIR, { recursive: true }) + const ext = path.extname(file.originalname) || '.jpg' + const filename = `product_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}` + await fs.writeFile(path.join(UPLOADS_DIR, filename), file.buffer) + return { url: `http://localhost:4000/uploads/${filename}` } + } +} diff --git a/apps/api/src/product/product.module.ts b/apps/api/src/product/product.module.ts new file mode 100644 index 0000000..1446bd5 --- /dev/null +++ b/apps/api/src/product/product.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ProductController } from './product.controller' +import { ProductService } from './product.service' + +@Module({ + controllers: [ProductController], + providers: [ProductService], + exports: [ProductService], +}) +export class ProductModule {} diff --git a/apps/api/src/product/product.service.ts b/apps/api/src/product/product.service.ts new file mode 100644 index 0000000..e312604 --- /dev/null +++ b/apps/api/src/product/product.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class ProductService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.product.findMany({ orderBy: { createdAt: 'desc' } }) + } + + async findOne(id: string) { + return this.prisma.product.findUnique({ where: { id } }) + } + + async create(data: { + name: string + spec: string + unit: string + price: number + quantity: number + category: string + }) { + return this.prisma.product.create({ data }) + } + + async update(id: string, data: any) { + return this.prisma.product.update({ where: { id }, data }) + } + + async publish(id: string) { + return this.prisma.product.update({ + where: { id }, + data: { status: 'published' }, + }) + } + + async batchPublish(ids: string[]) { + return this.prisma.product.updateMany({ + where: { id: { in: ids } }, + data: { status: 'published' }, + }) + } + + async remove(id: string) { + return this.prisma.product.delete({ where: { id } }) + } +} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts new file mode 100644 index 0000000..d60fd5e --- /dev/null +++ b/apps/api/src/reports/reports.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Post, Body, Param } from '@nestjs/common' +import { ReportsService } from './reports.service' + +@Controller('api/reports') +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get() + findAll() { + return this.reportsService.findAll() + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.reportsService.findOne(id) + } + + @Post() + create(@Body() body: { title: string; type: string; config?: any }) { + return this.reportsService.create(body) + } + + @Post(':id/send') + send(@Param('id') id: string, @Body('channels') channels: string[]) { + return this.reportsService.send(id, channels || []) + } +} diff --git a/apps/api/src/reports/reports.module.ts b/apps/api/src/reports/reports.module.ts new file mode 100644 index 0000000..7e913d6 --- /dev/null +++ b/apps/api/src/reports/reports.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from '../prisma/prisma.module' +import { ReportsService } from './reports.service' +import { ReportsController } from './reports.controller' + +@Module({ + imports: [PrismaModule], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts new file mode 100644 index 0000000..657aee3 --- /dev/null +++ b/apps/api/src/reports/reports.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class ReportsService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.report.findMany({ orderBy: { createdAt: 'desc' } }) + } + + async findOne(id: string) { + return this.prisma.report.findUnique({ where: { id } }) + } + + async create(data: { title: string; type: string; config?: any }) { + return this.prisma.report.create({ + data: { + title: data.title, + type: data.type, + config: data.config ? JSON.stringify(data.config) : null, + }, + }) + } + + async send(id: string, channels: string[]) { + return this.prisma.report.update({ + where: { id }, + data: { channels: JSON.stringify(channels), status: '已完成' }, + }) + } +} diff --git a/apps/api/src/rules/rules.controller.ts b/apps/api/src/rules/rules.controller.ts new file mode 100644 index 0000000..03331c1 --- /dev/null +++ b/apps/api/src/rules/rules.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common' +import { RulesService } from './rules.service' + +@Controller('api/rules') +export class RulesController { + constructor(private readonly rulesService: RulesService) {} + + @Get() + findAll(@Query('type') type?: string) { + return this.rulesService.findAll(type) + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.rulesService.findOne(id) + } + + @Post() + create(@Body() body: { name: string; type: string; config: any }) { + return this.rulesService.create(body) + } + + @Put(':id') + update(@Param('id') id: string, @Body() body: { name?: string; config?: any; enabled?: boolean }) { + return this.rulesService.update(id, body) + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.rulesService.remove(id) + } + + @Post(':id/versions') + createVersion(@Param('id') id: string) { + return this.rulesService.createVersion(id) + } + + @Get(':id/versions') + getVersions(@Param('id') id: string) { + return this.rulesService.getVersions(id) + } + + @Post('versions/:versionId/rollback') + rollback(@Param('versionId') versionId: string) { + return this.rulesService.rollback(versionId) + } +} diff --git a/apps/api/src/rules/rules.module.ts b/apps/api/src/rules/rules.module.ts new file mode 100644 index 0000000..df7b0d3 --- /dev/null +++ b/apps/api/src/rules/rules.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from '../prisma/prisma.module' +import { RulesService } from './rules.service' +import { RulesController } from './rules.controller' + +@Module({ + imports: [PrismaModule], + controllers: [RulesController], + providers: [RulesService], +}) +export class RulesModule {} diff --git a/apps/api/src/rules/rules.service.ts b/apps/api/src/rules/rules.service.ts new file mode 100644 index 0000000..9f60e2b --- /dev/null +++ b/apps/api/src/rules/rules.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class RulesService { + constructor(private prisma: PrismaService) {} + + async findAll(type?: string) { + const where: any = {} + if (type) where.type = type + return this.prisma.rule.findMany({ where, orderBy: { createdAt: 'desc' } }) + } + + async findOne(id: string) { + return this.prisma.rule.findUnique({ where: { id } }) + } + + async create(data: { name: string; type: string; config: any }) { + return this.prisma.rule.create({ + data: { name: data.name, type: data.type, config: JSON.stringify(data.config) }, + }) + } + + async update(id: string, data: { name?: string; config?: any; enabled?: boolean }) { + const updateData: any = {} + if (data.name !== undefined) updateData.name = data.name + if (data.enabled !== undefined) updateData.enabled = data.enabled + if (data.config) updateData.config = JSON.stringify(data.config) + return this.prisma.rule.update({ where: { id }, data: updateData }) + } + + async remove(id: string) { + return this.prisma.rule.delete({ where: { id } }) + } + + async createVersion(ruleId: string) { + const rule = await this.findOne(ruleId) + if (!rule) throw new Error('规则不存在') + const versions = await this.prisma.ruleVersion.findMany({ + where: { ruleId }, + orderBy: { version: 'desc' }, + take: 1, + }) + const nextVersion = versions.length > 0 ? versions[0].version + 1 : 1 + return this.prisma.ruleVersion.create({ + data: { ruleId, version: nextVersion, config: rule.config }, + }) + } + + async getVersions(ruleId: string) { + return this.prisma.ruleVersion.findMany({ + where: { ruleId }, + orderBy: { version: 'desc' }, + }) + } + + async rollback(versionId: string) { + const version = await this.prisma.ruleVersion.findUnique({ where: { id: versionId } }) + if (!version) throw new Error('版本不存在') + return this.prisma.rule.update({ + where: { id: version.ruleId }, + data: { config: version.config }, + }) + } +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..e0bc3b1 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseGuards } from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' +import { UsersService } from './users.service' + +@Controller('api/users') +@UseGuards(AuthGuard('jwt')) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + findAll() { + return this.usersService.findAll() + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..cf3eca9 --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { PrismaModule } from '../prisma/prisma.module' +import { UsersService } from './users.service' +import { UsersController } from './users.controller' + +@Module({ + imports: [PrismaModule], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..2c38842 --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' + +@Injectable() +export class UsersService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.user.findMany({ + select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true }, + }) + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..1d5ad22 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "prisma/**/*"] +} diff --git a/apps/web/.env.production b/apps/web/.env.production new file mode 100644 index 0000000..afb3e74 --- /dev/null +++ b/apps/web/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL= diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..634441a --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,55 @@ +# apps/web — Next.js 14 前端 + +**生成:** 2026-06-05 +**分支:** main + +## OVERVIEW + +Next.js 14 App Router + Tailwind CSS 3 的 SPA(无路由)。`useState` 切换 14 组件。端口 3001。 + +## STRUCTURE + +``` +web/ +├── src/ +│ ├── app/ +│ │ ├── layout.tsx # 根布局(html lang=zh-CN + Providers) +│ │ └── page.tsx # SPA 根(14 页面条件渲染) +│ ├── components/ # 14 个页面/组件 +│ │ ├── LoginPage.tsx +│ │ ├── AuthProvider.tsx # JWT 认证上下文 +│ │ ├── Sidebar.tsx +│ │ ├── OverviewPage.tsx +│ │ ├── InquiryPage.tsx # 线索列表 +│ │ ├── LeadDetailPage.tsx +│ │ ├── ProductPage.tsx +│ │ ├── ComparisonPage.tsx +│ │ ├── ImagePage.tsx +│ │ ├── RuleCenterPage.tsx # 含采集器配置 +│ │ ├── ReportCenterPage.tsx +│ │ ├── SystemMonitorPage.tsx +│ │ └── AdminPage.tsx +│ └── lib/ +│ └── apiBase.ts # API_BASE = env ?? 'http://localhost:4000' +``` + +## WHERE TO LOOK + +| 任务 | Location | Notes | +|------|----------|-------| +| 增加页面 | `src/components/` + 更新 `page.tsx` 的 Page 类型 | SPA 无路由,手动注册 | +| 修改样式 | `tailwind.config.ts` | secondary(#0051d5), border-subtle, surface-container 系 | +| API 调用 | `src/lib/apiBase.ts` | 生产 Nginx 反代 `/api` | +| 认证 | `AuthProvider.tsx` | localStorage key `auth` | + +## CONVENTIONS + +- Prettier: noSemi, singleQuote, trailingComma: all, printWidth: 100 +- 路径别名 `@/` → `src/` +- 导航通过 `onNavigate` prop 传 page 字符串,参数通过 props +- JWT 存 localStorage key `auth` + +## ANTI-PATTERNS + +- 不加路由库,保持 SPA useState 模式 +- 不在 components 外部加样式文件 diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js new file mode 100644 index 0000000..807a18d --- /dev/null +++ b/apps/web/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { protocol: 'https', hostname: 'picsum.photos' }, + ], + }, +} + +module.exports = nextConfig diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..911d5e8 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@pa/web", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "clean": "rm -rf .next .turbo" + }, + "dependencies": { + "file-saver": "^2.0.5", + "jszip": "^3.10.1", + "next": "^14.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/file-saver": "^2.0.7", + "@types/node": "^20.11.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.17", + "eslint": "^8.57.0", + "eslint-config-next": "^14.1.0", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.4.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..f15a261 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,8 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + background: #f7f9fb; + color: #191c1e; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..7dcbe6c --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import './globals.css' +import Providers from '@/components/Providers' + +export const metadata: Metadata = { + title: '平台助手 - Platform Assistant', + description: 'AI 智能体赋能 B2B 交易效率提升', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..b84f2d9 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useState } from 'react' +import { useAuth } from '@/components/AuthProvider' +import Sidebar from '@/components/Sidebar' +import LoginPage from '@/components/LoginPage' +import OverviewPage from '@/components/OverviewPage' +import InquiryPage from '@/components/InquiryPage' +import ImagePage from '@/components/ImagePage' +import ProductPage from '@/components/ProductPage' +import ComparisonPage from '@/components/ComparisonPage' +import LeadDetailPage from '@/components/LeadDetailPage' +import ReportCenterPage from '@/components/ReportCenterPage' +import RuleCenterPage from '@/components/RuleCenterPage' +import SystemMonitorPage from '@/components/SystemMonitorPage' +import AdminPage from '@/components/AdminPage' + +type Page = 'overview' | 'inquiry' | 'image' | 'product' | 'comparison' | 'lead-detail' | 'report-center' | 'rule-center' | 'system-monitor' | 'admin-tenants' | 'admin-users' | 'admin-stats' | 'admin-configs' | 'admin-plans' + +export default function Home() { + const { user, loading, logout } = useAuth() + const [page, setPage] = useState('overview') + const [leadDetailId, setLeadDetailId] = useState(null) + + const handleLeadDetail = (id: string) => { + setLeadDetailId(id) + setPage('lead-detail') + } + + if (loading) { + return ( +
+
加载中...
+
+ ) + } + + if (!user) return + + return ( +
+ +
+ {page === 'overview' && setPage(p as Page)} />} + {page === 'inquiry' && } + {page === 'image' && } + {page === 'product' && } + {page === 'comparison' && } + {page === 'lead-detail' && setPage(p as Page)} />} + {page === 'report-center' && } + {page === 'rule-center' && } + {page === 'system-monitor' && } + {page.startsWith('admin-') && } +
+
+ ) +} diff --git a/apps/web/src/components/AdminPage.tsx b/apps/web/src/components/AdminPage.tsx new file mode 100644 index 0000000..dce0c3c --- /dev/null +++ b/apps/web/src/components/AdminPage.tsx @@ -0,0 +1,1598 @@ +'use client' + +import React, { useEffect, useState, useCallback } from 'react' +import { API_BASE } from '@/lib/apiBase' + +// ---- Types ---- + +interface Tenant { + id: string; companyName: string; contactName: string | null; contactEmail: string | null + plan: string; status: string; validFrom: string; validUntil: string + platformType: string | null; platformUrl: string | null + features: string | null; usageQuotas: string | null + createdAt: string; updatedAt: string; _daysUntilExpiry?: number; _userCount?: number +} + +interface User { + id: string; email: string | null; username: string | null; name: string | null; role: string; isActive: boolean + tenantId: string | null; tenant: { companyName: string } | null; createdAt: string +} + +interface SystemConfig { + id: string; key: string; value: string; group: string; description: string | null + createdAt: string; updatedAt: string +} + +interface Stats { + overview: { tenantCount: number; userCount: number; productCount: number; leadCount: number; imageCount: number; inquiryCount: number } + perTenant: { + id: string; companyName: string; plan: string; status: string; validUntil: string + userCount: number + quotas: { monthlyLeads: number; monthlyImages: number; monthlyProducts: number; monthlyComparisons: number; maxUsers: number } + usage: { leads: number; images: number; products: number; comparisons: number; users: number } + }[] +} + +interface Features { inquiry: boolean; imageGen: boolean; product: boolean; comparison: boolean } +interface UsageQuotas { monthlyLeads: number; monthlyImages: number; monthlyProducts: number; monthlyComparisons: number; maxUsers: number } + +const DEFAULT_FEATURES: Features = { inquiry: true, imageGen: true, product: true, comparison: true } +const DEFAULT_QUOTAS: UsageQuotas = { monthlyLeads: 100, monthlyImages: 50, monthlyProducts: 100, monthlyComparisons: 50, maxUsers: 5 } + +type Tab = 'tenants' | 'users' | 'stats' | 'configs' | 'plans' + +const PLAN_LABELS: Record = { basic: '基础版', pro: '专业版', enterprise: '旗舰版' } +const PLAN_COLORS: Record = { basic: 'bg-gray-100 text-gray-700', pro: 'bg-blue-50 text-blue-700', enterprise: 'bg-purple-50 text-purple-700' } +const STATUS_LABELS: Record = { active: '正常', inactive: '已停用', expired: '已过期' } +const STATUS_COLORS: Record = { active: 'bg-green-50 text-green-700', inactive: 'bg-red-50 text-red-700', expired: 'bg-yellow-50 text-yellow-700' } +const PLATFORM_LABELS: Record = { jubian: '巨变平台', custom: '自建平台' } +const FEATURE_LABELS: Record = { inquiry: '询价发布', imageGen: '商品生图', product: '商品上架', comparison: '智能比价' } +const CONFIG_GROUPS: Record = { ai: 'AI 模型', platform: '平台对接', system: '系统参数' } + +const API = `${API_BASE}/api/admin` +const authHeaders = (): Record => { + const auth = localStorage.getItem('auth') + return { 'Content-Type': 'application/json', Authorization: `Bearer ${auth ? JSON.parse(auth).token : ''}` } +} + +function toastManager() { + const [toast, setToast] = useState('') + const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) } + return { toast, show } +} + +export default function AdminPage({ section }: { section: Tab }) { + const { toast, show: showToast } = toastManager() + + return ( +
+ {toast &&
{toast}
} + +
+
+

运营后台

+

平台管理控制台

+
+
+ + {section === 'tenants' && } + {section === 'users' && } + {section === 'stats' && } + {section === 'configs' && } + {section === 'plans' && } +
+ ) +} + +// ====================== Tenant Tab ====================== + +function TenantTab({ showToast }: { showToast: (m: string) => void }) { + const [tenants, setTenants] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState({ companyName: '', contactName: '', contactEmail: '', plan: 'basic', status: 'active', validFrom: '', validUntil: '', platformType: 'jubian', platformUrl: '', platformApiKey: '' }) + const [features, setFeatures] = useState(DEFAULT_FEATURES) + const [quotas, setQuotas] = useState(DEFAULT_QUOTAS) + const [adminUsername, setAdminUsername] = useState('') + const [adminPassword, setAdminPassword] = useState('') + const [adminName, setAdminName] = useState('') + const [saving, setSaving] = useState(false) + const [planConfig, setPlanConfig] = useState | null>(null) + const [userManager, setUserManager] = useState<{ tenantId: string; companyName: string } | null>(null) + + const loadTenants = useCallback(async () => { + try { + const res = await fetch(`${API}/tenants`, { headers: authHeaders() }) + if (res.ok) setTenants(await res.json()) + } catch { showToast('加载失败') + } finally { setLoading(false) } + }, [showToast]) + + useEffect(() => { loadTenants() }, [loadTenants]) + + useEffect(() => { + fetch(`${API}/plan-config`, { headers: authHeaders() }) + .then((r) => r.ok && r.json()) + .then((cfg) => setPlanConfig(cfg)) + .catch(() => {}) + }, []) + + const filtered = tenants.filter((t) => + [t.companyName, t.contactName, t.contactEmail].some((v) => v?.toLowerCase().includes(search.toLowerCase())) + ) + + const openCreate = () => { + setForm({ companyName: '', contactName: '', contactEmail: '', plan: 'basic', status: 'active', validFrom: '', validUntil: '', platformType: 'jubian', platformUrl: '', platformApiKey: '' }) + const q = planConfig?.basic?.quotas || DEFAULT_QUOTAS + setFeatures(planConfig?.basic?.features || DEFAULT_FEATURES); setQuotas(q); setEditingId(null); setShowForm(true) + setAdminUsername(''); setAdminPassword(''); setAdminName('') + } + + const openEdit = (t: Tenant) => { + setForm({ + companyName: t.companyName, contactName: t.contactName || '', contactEmail: t.contactEmail || '', + plan: t.plan, status: t.status, validFrom: t.validFrom?.slice(0, 10) || '', validUntil: t.validUntil?.slice(0, 10) || '', + platformType: t.platformType || 'jubian', platformUrl: t.platformUrl || '', platformApiKey: '', + }) + try { setFeatures(t.features ? JSON.parse(t.features) : DEFAULT_FEATURES) } catch { setFeatures(DEFAULT_FEATURES) } + try { setQuotas(t.usageQuotas ? JSON.parse(t.usageQuotas) : DEFAULT_QUOTAS) } catch { setQuotas(DEFAULT_QUOTAS) } + setEditingId(t.id); setShowForm(true) + } + + const handleSave = async () => { + if (!form.companyName || !form.validUntil) { showToast('请填写公司名和有效期'); return } + setSaving(true) + try { + const body = { + companyName: form.companyName, contactName: form.contactName || undefined, contactEmail: form.contactEmail || undefined, + plan: form.plan, status: form.status, validFrom: form.validFrom || undefined, validUntil: form.validUntil, + features: JSON.stringify(features), usageQuotas: JSON.stringify(quotas), + } + if (!editingId) { + Object.assign(body, { platformApiKey: form.platformApiKey || '', adminUsername: adminUsername || undefined, adminPassword: adminPassword || undefined, adminName: adminName || undefined }) + } else { + Object.assign(body, { platformType: form.platformType, platformUrl: form.platformUrl, platformApiKey: form.platformApiKey }) + } + const res = await fetch(editingId ? `${API}/tenants/${editingId}` : `${API}/tenants`, { + method: editingId ? 'PATCH' : 'POST', headers: authHeaders(), + body: JSON.stringify(body), + }) + if (!res.ok) { showToast('保存失败'); return } + showToast(editingId ? '更新成功' : '创建成功'); setShowForm(false); loadTenants() + } catch { showToast('保存失败') + } finally { setSaving(false) } + } + + const handleDelete = async (id: string) => { + if (!confirm('确定删除该租户?')) return + const res = await fetch(`${API}/tenants/${id}`, { method: 'DELETE', headers: authHeaders() }) + if (res.ok) { showToast('删除成功'); loadTenants() } else showToast('删除失败') + } + + const expiryBadge = (days: number | undefined) => { + if (days === undefined) return null + if (days < 0) return 已过期 {Math.abs(days)}天 + if (days <= 7) return 即将到期 {days}天 + if (days <= 30) return {days}天后到期 + return {days}天后到期 + } + + const fmt = (d: string) => d ? new Date(d).toLocaleDateString('zh-CN') : '-' + + return ( +
+
+ setSearch(e.target.value)} + className="w-full max-w-md px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> + +
+ +
+
+ + + + + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((t) => { + const feat = (() => { try { return JSON.parse(t.features || '{}') as Features } catch { return {} as Features } })() + return ( + + + + + + + + + + + ) + })} + +
公司名订阅版本状态有效期 / 到期提醒联系人功能对接平台操作
加载中...
暂无租户数据
{t.companyName} + + {PLAN_LABELS[t.plan] || t.plan} + + + + {STATUS_LABELS[t.status] || t.status} + + +
{fmt(t.validFrom)} ~ {fmt(t.validUntil)}
+
{expiryBadge(t._daysUntilExpiry)}
+
+
{t.contactName || '-'}
+ {t.contactEmail &&
{t.contactEmail}
} +
+
+ {(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([k, label]) => ( + + {label} + + ))} +
+
+ + {PLATFORM_LABELS[t.platformType || ''] || t.platformType || '-'} + + +
+ + + +
+
+
+
+ + {showForm && ( +
setShowForm(false)}> +
e.stopPropagation()}> +
+

{editingId ? '编辑租户' : '新建租户'}

+
+
+ {/* 基本信息 */} +
+
+ + setForm({ ...form, companyName: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setForm({ ...form, contactName: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setForm({ ...form, contactEmail: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ {/* 订阅版本 & 状态 */} +
+
+ + +
+
+ + +
+
+ + setForm({ ...form, validFrom: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setForm({ ...form, validUntil: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ {/* 平台对接配置 */} +
+

平台对接配置

+
+
+ + +
+
+ + setForm({ ...form, platformUrl: e.target.value })} placeholder="https://api.example.com" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setForm({ ...form, platformApiKey: e.target.value })} placeholder="用于平台对接的 API 密钥" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+
+ {!editingId && ( +
+

管理员账号(创建时可选)

+
+
+ + setAdminUsername(e.target.value)} placeholder="admin" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setAdminPassword(e.target.value)} placeholder="至少6位" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setAdminName(e.target.value)} placeholder="可选,默认显示'公司名管理员'" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+
+ )} + {/* 功能权限 */} +
+

功能权限

+
+ {(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([k, label]) => ( + + ))} +
+
+ {/* 用量配额 */} +
+

用量配额

+
+
+ + setQuotas({ ...quotas, monthlyLeads: +e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setQuotas({ ...quotas, monthlyImages: +e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setQuotas({ ...quotas, monthlyProducts: +e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setQuotas({ ...quotas, monthlyComparisons: +e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setQuotas({ ...quotas, maxUsers: +e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+
+
+
+ + +
+
+
+ )} + + {/* User Manager Modal */} + {userManager && setUserManager(null)} + showToast={showToast} + />} +
+ ) +} + +// ====================== Tenant User Manager ====================== + +function TenantUserManager({ tenantId, companyName, onClose, showToast }: { tenantId: string; companyName: string; onClose: () => void; showToast: (m: string) => void }) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ username: '', password: '', name: '' }) + const [saving, setSaving] = useState(false) + const [resetPwd, setResetPwd] = useState<{ id: string; name: string } | null>(null) + const [newPwd, setNewPwd] = useState('') + + const loadUsers = useCallback(async () => { + try { + const res = await fetch(`${API}/users`, { headers: authHeaders() }) + if (res.ok) { + const all: User[] = await res.json() + setUsers(all.filter((u) => u.tenantId === tenantId)) + } + } catch { showToast('加载失败') + } finally { setLoading(false) } + }, [tenantId, showToast]) + + useEffect(() => { loadUsers() }, [loadUsers]) + + const handleCreate = async () => { + if (!form.username || !form.password) { showToast('请填写用户名和密码'); return } + if (form.password.length < 6) { showToast('密码至少6位'); return } + setSaving(true) + try { + const res = await fetch(`${API}/users`, { + method: 'POST', headers: authHeaders(), + body: JSON.stringify({ ...form, tenantId }), + }) + if (res.ok) { showToast('创建成功'); setShowForm(false); setForm({ username: '', password: '', name: '' }); loadUsers() } + else { const err = await res.json(); showToast(err.message || '创建失败') } + } catch { showToast('创建失败') + } finally { setSaving(false) } + } + + const handleResetPwd = async () => { + if (!resetPwd || !newPwd || newPwd.length < 6) { showToast('密码至少6位'); return } + setSaving(true) + try { + const res = await fetch(`${API}/users/${resetPwd.id}/reset-password`, { + method: 'POST', headers: authHeaders(), body: JSON.stringify({ password: newPwd }), + }) + if (res.ok) { showToast('密码重置成功'); setResetPwd(null); setNewPwd(''); loadUsers() } else showToast('重置失败') + } catch { showToast('重置失败') + } finally { setSaving(false) } + } + + const handleToggleActive = async (id: string) => { + const res = await fetch(`${API}/users/${id}/toggle-active`, { method: 'POST', headers: authHeaders() }) + if (res.ok) loadUsers(); else showToast('操作失败') + } + + return ( +
+
e.stopPropagation()}> +
+
+

用户管理

+

{companyName}

+
+ +
+ +
+
+ 共 {users.length} 个用户 + +
+ + {loading ? ( +
加载中...
+ ) : users.length === 0 ? ( +
暂无用户
+ ) : ( + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
姓名用户名角色状态创建时间操作
{u.name || '-'}{u.username || u.email} + + {u.role === 'admin' ? '管理员' : '普通用户'} + + + + {u.isActive ? '正常' : '已禁用'} + + {u.createdAt ? new Date(u.createdAt).toLocaleDateString('zh-CN') : '-'} +
+ + +
+
+ )} +
+ + {/* Create user form */} + {showForm && ( +
setShowForm(false)}> +
e.stopPropagation()}> +
+

新增用户 — {companyName}

+
+
+
+ + setForm({ ...form, username: e.target.value })} placeholder="请输入用户名" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setForm({ ...form, password: e.target.value })} placeholder="至少6位" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setForm({ ...form, name: e.target.value })} placeholder="可选" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+
+ + +
+
+
+ )} + + {/* Reset password */} + {resetPwd && ( +
setResetPwd(null)}> +
e.stopPropagation()}> +
+

重置密码

+

用户:{resetPwd.name}

+
+
+ + setNewPwd(e.target.value)} placeholder="至少6位" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + +
+
+
+ )} +
+
+ ) +} + +// ====================== User Tab ====================== + +function UserTab({ showToast }: { showToast: (m: string) => void }) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [resetPwd, setResetPwd] = useState<{ id: string; name: string } | null>(null) + const [newPwd, setNewPwd] = useState('') + const [saving, setSaving] = useState(false) + const [showUserForm, setShowUserForm] = useState(false) + const [userForm, setUserForm] = useState({ username: '', password: '', name: '', tenantId: '' }) + const [tenants, setTenants] = useState([]) + const [userSaving, setUserSaving] = useState(false) + + const loadUsers = useCallback(async () => { + try { + const res = await fetch(`${API}/users`, { headers: authHeaders() }) + if (res.ok) setUsers(await res.json()) + } catch { showToast('加载失败') + } finally { setLoading(false) } + }, [showToast]) + + useEffect(() => { loadUsers() }, [loadUsers]) + + useEffect(() => { + fetch(`${API}/tenants`, { headers: authHeaders() }).then(r => r.ok && r.json()).then(setTenants) + }, []) + + const handleCreateUser = async () => { + if (!userForm.username || !userForm.password) { showToast('请填写用户名和密码'); return } + if (userForm.password.length < 6) { showToast('密码至少6位'); return } + setUserSaving(true) + try { + const res = await fetch(`${API}/users`, { + method: 'POST', headers: authHeaders(), body: JSON.stringify(userForm), + }) + if (res.ok) { showToast('创建成功'); setShowUserForm(false); setUserForm({ username: '', password: '', name: '', tenantId: '' }); loadUsers() } + else { const err = await res.json(); showToast(err.message || '创建失败') } + } catch { showToast('创建失败') + } finally { setUserSaving(false) } + } + + const filtered = users.filter((u) => + [u.username, u.email, u.name, u.tenant?.companyName].some((v) => v?.toLowerCase().includes(search.toLowerCase())) + ) + + const handleResetPwd = async () => { + if (!resetPwd || !newPwd || newPwd.length < 6) { showToast('密码至少6位'); return } + setSaving(true) + try { + const res = await fetch(`${API}/users/${resetPwd.id}/reset-password`, { + method: 'POST', headers: authHeaders(), body: JSON.stringify({ password: newPwd }), + }) + if (res.ok) { showToast('密码重置成功'); setResetPwd(null); setNewPwd('') } else showToast('重置失败') + } catch { showToast('重置失败') + } finally { setSaving(false) } + } + + const handleToggleActive = async (id: string) => { + const res = await fetch(`${API}/users/${id}/toggle-active`, { method: 'POST', headers: authHeaders() }) + if (res.ok) loadUsers(); else showToast('操作失败') + } + + return ( +
+
+ setSearch(e.target.value)} + className="w-full max-w-md px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> + +
+ +
+ + + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((u) => ( + + + + + + + + + ))} + +
姓名邮箱角色所属租户状态操作
加载中...
暂无用户数据
{u.name || '-'}{u.username || u.email} + + {u.role === 'admin' ? '管理员' : '普通用户'} + + {u.tenant?.companyName || '-'} + + {u.isActive ? '正常' : '已禁用'} + + +
+ + +
+
+
+ + {showUserForm && ( +
setShowUserForm(false)}> +
e.stopPropagation()}> +
+

新增用户

+
+
+
+ + setUserForm({ ...userForm, username: e.target.value })} placeholder="请输入用户名" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setUserForm({ ...userForm, password: e.target.value })} placeholder="至少6位" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setUserForm({ ...userForm, name: e.target.value })} placeholder="可选" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + +
+
+
+ + +
+
+
+ )} + {resetPwd && ( +
setResetPwd(null)}> +
e.stopPropagation()}> +
+

重置密码

+

用户:{resetPwd.name}

+
+
+ + setNewPwd(e.target.value)} placeholder="至少6位" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + +
+
+
+ )} +
+ ) +} + +// ====================== Stats Tab ====================== + +function StatsTab() { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [selectedTenant, setSelectedTenant] = useState(null) + + useEffect(() => { + (async () => { + try { + const res = await fetch(`${API}/stats`, { headers: authHeaders() }) + if (res.ok) setStats(await res.json()) + } catch {} finally { setLoading(false) } + })() + }, []) + + if (loading) return
加载中...
+ if (!stats) return
暂无数据
+ + const cards = [ + { label: '租户总数', value: stats.overview.tenantCount, color: 'bg-blue-50 text-blue-700' }, + { label: '用户总数', value: stats.overview.userCount, color: 'bg-purple-50 text-purple-700' }, + { label: '商品数', value: stats.overview.productCount, color: 'bg-green-50 text-green-700' }, + { label: '线索数', value: stats.overview.leadCount, color: 'bg-orange-50 text-orange-700' }, + { label: '生成图片', value: stats.overview.imageCount, color: 'bg-pink-50 text-pink-700' }, + { label: '询价数', value: stats.overview.inquiryCount, color: 'bg-teal-50 text-teal-700' }, + ] + + const METRICS: { key: keyof Stats['perTenant'][0]['usage']; label: string; quotaKey: keyof Stats['perTenant'][0]['quotas']; desc: string }[] = [ + { key: 'users', label: '用户数', quotaKey: 'maxUsers', desc: '已用 / 上限' }, + { key: 'products', label: '商品上架', quotaKey: 'monthlyProducts', desc: '已上架 / 月配额' }, + { key: 'leads', label: '月线索', quotaKey: 'monthlyLeads', desc: '本月 / 月配额' }, + { key: 'images', label: '月生图', quotaKey: 'monthlyImages', desc: '本月 / 月配额' }, + { key: 'comparisons', label: '月比价', quotaKey: 'monthlyComparisons', desc: '本月 / 月配额' }, + ] + + function UsageBar({ usage, quota }: { usage: number; quota: number }) { + if (quota === 0) return 未开通 + const pct = Math.min(usage / quota, 1) + const color = pct >= 1 ? 'bg-red-500' : pct >= 0.8 ? 'bg-yellow-500' : 'bg-green-500' + return ( +
+
+
+
+ + {usage} / {quota} + +
+ ) + } + + const filtered = stats.perTenant.filter((t) => { + if (statusFilter !== 'all' && t.status !== statusFilter) return false + if (search && !t.companyName.toLowerCase().includes(search.toLowerCase())) return false + return true + }) + + // Detail view + if (selectedTenant) { + const t = selectedTenant + return ( +
+
+ +
+
+
+ {t.companyName} + {PLAN_LABELS[t.plan] || t.plan} + {STATUS_LABELS[t.status] || t.status} + + 有效期至 {t.validUntil ? new Date(t.validUntil).toLocaleDateString('zh-CN') : '-'} + +
+
+ {METRICS.map((m) => ( +
+
+ {m.label} + {m.desc} +
+ +
+ ))} +
+
+
+ ) + } + + // List view + return ( +
+
+ {cards.map((c) => ( +
+

{c.label}

+

{c.value}

+
+ ))} +
+ +
+
+ setSearch(e.target.value)} + className="max-w-xs px-3 py-1.5 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> + + 共 {filtered.length} 个租户 +
+ + + + + + + + + + + + + + + + {filtered.length === 0 ? ( + + ) : filtered.map((t) => ( + setSelectedTenant(t)}> + + + + + + + + + + + ))} + +
企业名称版本状态有效期用户商品上架月线索月生图月比价
无匹配租户
{t.companyName} + {PLAN_LABELS[t.plan] || t.plan} + + {STATUS_LABELS[t.status] || t.status} + {t.validUntil ? new Date(t.validUntil).toLocaleDateString('zh-CN') : '-'}
+
+
+ ) +} + +// ====================== Config Tab ====================== + +interface Integration { + id: string; name: string; type: string; endpoint: string; appId: string | null; apiKey: string; customerCode: string + status: string; tools: string | null; lastConnected: string | null; error: string | null + createdAt: string; updatedAt: string +} + +const INTEGRATION_API = `${API_BASE}/api/integrations` + +function ConfigTab({ showToast }: { showToast: (m: string) => void }) { + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(true) + const [editing, setEditing] = useState(null) + const [editValue, setEditValue] = useState('') + const [newConfig, setNewConfig] = useState({ key: '', value: '', group: 'system', description: '' }) + const [showNew, setShowNew] = useState(false) + const [saving, setSaving] = useState(false) + + const [integrations, setIntegrations] = useState([]) + const [integrationLoading, setIntegrationLoading] = useState(true) + const [showIntegrationForm, setShowIntegrationForm] = useState(false) + const [editingIntegration, setEditingIntegration] = useState(null) + const [integrationForm, setIntegrationForm] = useState({ + name: '', endpoint: 'http://100.111.84.16:3002/mcp', appId: '', apiKey: '', customerCode: '' + }) + const [testingId, setTestingId] = useState(null) + const [connectingId, setConnectingId] = useState(null) + + const DEVBOX_ADMIN_APPLY_URL = 'http://100.111.84.16:3000/openapi/app' + + const loadConfigs = useCallback(async () => { + try { + const res = await fetch(`${API}/configs`, { headers: authHeaders() }) + if (res.ok) setConfigs(await res.json()) + } catch { showToast('加载失败') + } finally { setLoading(false) } + }, [showToast]) + + const loadIntegrations = useCallback(async () => { + try { + const res = await fetch(INTEGRATION_API, { headers: authHeaders() }) + if (res.ok) setIntegrations(await res.json()) + } catch { showToast('加载对接配置失败') + } finally { setIntegrationLoading(false) } + }, [showToast]) + + useEffect(() => { loadConfigs(); loadIntegrations() }, [loadConfigs, loadIntegrations]) + + const grouped = configs.reduce((acc, c) => { + const subGroup = c.group === 'platform' ? `platform-${c.key.startsWith('jubian') ? 'jubian' : 'custom'}` : c.group + if (!acc[subGroup]) acc[subGroup] = [] + acc[subGroup].push(c) + return acc + }, {} as Record) + + const GROUP_LABELS: Record = { + ai: 'AI 模型', 'platform-jubian': '巨变平台配置', 'platform-custom': '自建平台配置', system: '系统参数', + } + + const handleSaveEdit = async () => { + if (!editing) return + setSaving(true) + try { + const res = await fetch(`${API}/configs`, { + method: 'POST', headers: authHeaders(), + body: JSON.stringify({ key: editing.key, value: editValue, group: editing.group, description: editing.description }), + }) + if (res.ok) { showToast('更新成功'); setEditing(null); loadConfigs() } else showToast('更新失败') + } catch { showToast('更新失败') + } finally { setSaving(false) } + } + + const handleCreate = async () => { + if (!newConfig.key || !newConfig.value) { showToast('请填写键和值'); return } + setSaving(true) + try { + const res = await fetch(`${API}/configs`, { + method: 'POST', headers: authHeaders(), + body: JSON.stringify(newConfig), + }) + if (res.ok) { showToast('创建成功'); setShowNew(false); setNewConfig({ key: '', value: '', group: 'system', description: '' }); loadConfigs() } + else showToast('创建失败') + } catch { showToast('创建失败') + } finally { setSaving(false) } + } + + const handleDelete = async (id: string) => { + if (!confirm('确定删除此配置?')) return + const res = await fetch(`${API}/configs/${id}`, { method: 'DELETE', headers: authHeaders() }) + if (res.ok) { showToast('删除成功'); loadConfigs() } else showToast('删除失败') + } + + const handleSaveIntegration = async () => { + if (!integrationForm.name || !integrationForm.endpoint) { showToast('请填写名称和 MCP 地址'); return } + if (!integrationForm.appId || !integrationForm.apiKey) { showToast('请填写 appId 和 appSecret(先在 devbox 运营后台申请)'); return } + setSaving(true) + try { + const body = { ...integrationForm, type: 'mcp' } + const res = await fetch(editingIntegration ? `${INTEGRATION_API}/${editingIntegration.id}` : INTEGRATION_API, { + method: editingIntegration ? 'PUT' : 'POST', headers: authHeaders(), body: JSON.stringify(body), + }) + if (res.ok) { showToast(editingIntegration ? '更新成功' : '创建成功'); setShowIntegrationForm(false); setEditingIntegration(null); loadIntegrations() } + else showToast('保存失败') + } catch { showToast('保存失败') + } finally { setSaving(false) } + } + + const handleDeleteIntegration = async (id: string) => { + if (!confirm('确定删除此对接配置?')) return + const res = await fetch(`${INTEGRATION_API}/${id}`, { method: 'DELETE', headers: authHeaders() }) + if (res.ok) { showToast('删除成功'); loadIntegrations() } else showToast('删除失败') + } + + const handleTestIntegration = async (id: string) => { + setTestingId(id) + try { + const res = await fetch(`${INTEGRATION_API}/${id}/connect`, { method: 'POST', headers: authHeaders() }) + const data = await res.json() + if (res.ok) { showToast(`测试成功!发现 ${data.tools?.length || 0} 个工具`); loadIntegrations() } + else showToast(data.error || data.message || '测试失败') + } catch { showToast('测试失败') + } finally { setTestingId(null) } + } + + const handleConnectIntegration = async (id: string) => { + setConnectingId(id) + try { + const res = await fetch(`${INTEGRATION_API}/${id}/connect`, { method: 'POST', headers: authHeaders() }) + if (res.ok) { showToast('连接成功'); loadIntegrations() } else showToast('连接失败') + } catch { showToast('连接失败') + } finally { setConnectingId(null) } + } + + const handleDisconnectIntegration = async (id: string) => { + try { + const res = await fetch(`${INTEGRATION_API}/${id}/disconnect`, { method: 'POST', headers: authHeaders() }) + if (res.ok) { showToast('已断开'); loadIntegrations() } else showToast('断开失败') + } catch { showToast('断开失败') } + } + + if (loading) return
加载中...
+ + return ( +
+
+

平台全局参数配置,修改后立即生效

+ +
+ +
+
+

MCP 对接配置

+ +
+
+ {integrationLoading ? ( +
加载中...
+ ) : integrations.length === 0 ? ( +
暂无对接配置,点击"新增对接"添加
+ ) : ( + + + + + + + + + + + + + + {integrations.map((item) => { + let toolsList: any[] = [] + try { + if (item.tools) toolsList = JSON.parse(item.tools) + } catch { /* ignore */ } + return ( + + + + + + + + + + + {toolsList.length > 0 && ( + + + + )} + + ) + })} + +
名称MCP 地址客户编码状态工具数最后连接操作
{item.name}{item.endpoint}{item.customerCode || '-'} + + {item.status === 'active' ? '已连接' : item.status === 'error' ? '错误' : '未连接'} + + {toolsList.length} + {item.lastConnected ? new Date(item.lastConnected).toLocaleString('zh-CN') : '-'} + +
+ + {item.status === 'active' && ( + + )} + {item.status !== 'active' && ( + + )} + + +
+
+
+ 已加载 Tools: + {toolsList.map((t: any) => ( + + {t.name} + + ))} +
+ {item.error && ( +
最近错误: {item.error}
+ )} +
+ )} +
+
+ + {Object.entries(grouped).map(([group, items]) => ( +
+

{GROUP_LABELS[group] || CONFIG_GROUPS[group] || group}

+
+ + + + + + + + + + + {items.map((c) => ( + + + + + + + ))} + +
配置键值说明操作
{c.key} + {c.value ? ( + {c.value} + ) : 未设置} + {c.description || '-'} +
+ + +
+
+
+
+ ))} + + {/* Edit modal */} + {editing && ( +
setEditing(null)}> +
e.stopPropagation()}> +
+

编辑配置

+

{editing.key}

+
+
+ + setEditValue(e.target.value)} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> + {editing.description &&

{editing.description}

} +
+
+ + +
+
+
+ )} + + {showNew && ( +
setShowNew(false)}> +
e.stopPropagation()}> +

新增配置

+
+
+ + setNewConfig({ ...newConfig, key: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" placeholder="例如: llm_model" /> +
+
+ + setNewConfig({ ...newConfig, value: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + +
+
+ + setNewConfig({ ...newConfig, description: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+
+ + +
+
+
+ )} + + {showIntegrationForm && ( +
{ setShowIntegrationForm(false); setEditingIntegration(null) }}> +
e.stopPropagation()}> +
+

{editingIntegration ? '编辑对接' : '新增对接'}

+

+ APIKey 需先在 + devbox B2B 运营后台 → 集成管理 → 应用列表 + 申请获取 +

+
+
+
+ + setIntegrationForm({ ...integrationForm, name: e.target.value })} + placeholder="例如: B2B 平台对接" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setIntegrationForm({ ...integrationForm, endpoint: e.target.value })} + placeholder="http://100.111.84.16:3002/mcp" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setIntegrationForm({ ...integrationForm, appId: e.target.value })} + placeholder="例如: app_2a66d12104e64b02a195" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm font-mono outline-none focus:border-secondary transition-colors" /> +

申请后从运营后台"应用列表"详情里复制

+
+
+ + setIntegrationForm({ ...integrationForm, apiKey: e.target.value })} + placeholder="申请时一次性返回, 仅显示一次, 请妥善保存" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +

AES-256-GCM 加密存储于本地, 不会明文返回列表

+
+
+ + setIntegrationForm({ ...integrationForm, customerCode: e.target.value })} + placeholder="例如: test-customer-001 (MCP 会从 app 自动识别, 这里仅做备注)" + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+
+ + +
+
+
+ )} +
+ ) +} + +// ====================== Plan Config Tab ====================== + +interface PlanEntry { + label: string + price: number + description: string + features: Features + quotas: UsageQuotas + platformCount: number + supportLevel: string +} + +const PLAN_KEYS = ['basic', 'pro', 'enterprise'] + +function PlanConfigTab({ showToast }: { showToast: (m: string) => void }) { + const [config, setConfig] = useState | null>(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [editPlan, setEditPlan] = useState(null) + const [editData, setEditData] = useState(null) + + const load = useCallback(async () => { + try { + const res = await fetch(`${API}/plan-config`, { headers: authHeaders() }) + if (res.ok) setConfig(await res.json()) + } catch { showToast('加载失败') + } finally { setLoading(false) } + }, [showToast]) + + useEffect(() => { load() }, [load]) + + const openEdit = (key: string) => { + if (!config) return + setEditPlan(key) + setEditData(JSON.parse(JSON.stringify(config[key]))) + } + + const handleSave = async () => { + if (!config || !editPlan || !editData) return + const updated = { ...config, [editPlan]: editData } + setSaving(true) + try { + const res = await fetch(`${API}/plan-config`, { + method: 'PUT', headers: authHeaders(), + body: JSON.stringify(updated), + }) + if (res.ok) { showToast('保存成功'); setConfig(updated); setEditPlan(null) } + else showToast('保存失败') + } catch { showToast('保存失败') + } finally { setSaving(false) } + } + + if (loading) return
加载中...
+ if (!config) return
暂无数据
+ + return ( +
+
+

配置订阅方案的定价、功能权限和用量配额。修改后创建租户时将自动应用新的默认值。

+
+ +
+ {PLAN_KEYS.map((key) => { + const p = config[key] + if (!p) return null + return ( +
+ {key === 'pro' &&
推荐
} +
+
+

{p.label}

+ {PLAN_LABELS[key]} +
+

{p.description}

+
+ ¥{p.price.toLocaleString()} + /年 +
折合 ¥{Math.round(p.price / 12)}/月
+
+
+
+
功能权限
+
+ {(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([fk, fl]) => ( + {fl} + ))} +
+
+
+
用量配额
+
+
月线索: {p.quotas.monthlyLeads >= 99999 ? '不限' : p.quotas.monthlyLeads} 条
+
月生图: {p.quotas.monthlyImages >= 99999 ? '不限' : p.quotas.monthlyImages} 张
+
月上架: {p.quotas.monthlyProducts >= 99999 ? '不限' : p.quotas.monthlyProducts} 条
+
月比价: {p.quotas.monthlyComparisons >= 99999 ? '不限' : p.quotas.monthlyComparisons} 次
+
用户数: {p.quotas.maxUsers >= 99999 ? '不限' : p.quotas.maxUsers} 人
+
+
+
+ 平台对接: + {p.platformCount >= 999 ? '不限' : p.platformCount} 个平台 +
+
+ 技术支持: + {p.supportLevel} +
+
+ +
+
+ ) + })} +
+ + {/* Edit modal */} + {editPlan && editData && ( +
setEditPlan(null)}> +
e.stopPropagation()}> +
+

编辑方案 — {editData.label}

+
+
+
+
+ + setEditData({ ...editData, label: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, price: +e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, description: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, platformCount: +e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, supportLevel: e.target.value })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ {/* Features */} +
+

功能权限

+
+ {(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([k, label]) => ( + + ))} +
+
+ {/* Quotas */} +
+

用量配额

+
+
+ + setEditData({ ...editData, quotas: { ...editData.quotas, monthlyLeads: +e.target.value } })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, quotas: { ...editData.quotas, monthlyImages: +e.target.value } })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, quotas: { ...editData.quotas, monthlyProducts: +e.target.value } })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, quotas: { ...editData.quotas, monthlyComparisons: +e.target.value } })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+ + setEditData({ ...editData, quotas: { ...editData.quotas, maxUsers: +e.target.value } })} + className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +
+
+
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/AuthProvider.tsx b/apps/web/src/components/AuthProvider.tsx new file mode 100644 index 0000000..f0d5b30 --- /dev/null +++ b/apps/web/src/components/AuthProvider.tsx @@ -0,0 +1,78 @@ +'use client' + +import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react' +import { API_BASE } from '@/lib/apiBase' + +interface User { + id: string + email: string + name: string + role: string +} + +interface AuthContextType { + user: User | null + token: string | null + login: (email: string, password: string) => Promise + logout: () => void + loading: boolean +} + +const AuthContext = createContext({} as AuthContextType) + +export function useAuth() { + return useContext(AuthContext) +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [token, setToken] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const saved = localStorage.getItem('auth') + if (saved) { + try { + const { token: t, user: u } = JSON.parse(saved) + setToken(t) + setUser(u) + fetch(`${API_BASE}/api/auth/profile`, { + headers: { Authorization: `Bearer ${t}` }, + }) + .then((r) => r.ok ? r.json().then(setUser) : logout()) + .catch(() => logout()) + } catch { + localStorage.removeItem('auth') + } + } + setLoading(false) + }, []) + + const login = useCallback(async (username: string, password: string) => { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (!res.ok) { + const err = await res.json() + throw new Error(err.message || '登录失败') + } + const data = await res.json() + setToken(data.access_token) + setUser(data.user) + localStorage.setItem('auth', JSON.stringify({ token: data.access_token, user: data.user })) + }, []) + + const logout = useCallback(() => { + setToken(null) + setUser(null) + localStorage.removeItem('auth') + }, []) + + return ( + + {children} + + ) +} diff --git a/apps/web/src/components/ComparisonPage.tsx b/apps/web/src/components/ComparisonPage.tsx new file mode 100644 index 0000000..957fdb2 --- /dev/null +++ b/apps/web/src/components/ComparisonPage.tsx @@ -0,0 +1,640 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { API_BASE } from '@/lib/apiBase' + +interface SupplierInfo { + id: string + name: string + price: number + unit: string + spec: string + brand?: string + minOrder?: number + imageUrl?: string +} + +interface ComparisonItem { + productName: string + spec: string + category: string + quantity: number + suppliers: SupplierInfo[] + bestPrice: number + bestSupplier: string + hasSupplier: boolean + savings: number + priceSpread: number +} + +interface SupplierUpload { + name: string + count: number + timestamp: number +} + +type Step = 'upload' | 'compare' | 'suggestion' + +const API = `${API_BASE}/api/comparison` + +const fmtPrice = (n?: number) => (n != null ? `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '¥0') + +export default function ComparisonPage() { + const [step, setStep] = useState('upload') + const [data, setData] = useState([]) + const [report, setReport] = useState(null) + const [loading, setLoading] = useState(true) + const [suppliers, setSuppliers] = useState([]) + const [toast, setToast] = useState('') + const [sortBy, setSortBy] = useState<'price' | 'name' | 'savings'>('price') + const [splitSupplierCount, setSplitSupplierCount] = useState(null) + + // Upload state + const [supplierName, setSupplierName] = useState('') + const [uploading, setUploading] = useState(false) + const [dragOver, setDragOver] = useState(false) + const fileRef = useRef(null) + + const showToast = (msg: string) => { + setToast(msg) + setTimeout(() => setToast(''), 2500) + } + + const loadData = async () => { + try { + const [compRes, suggRes] = await Promise.all([ + fetch(API), + fetch(`${API}/suggestion`, { method: 'POST' }), + ]) + const compData = await compRes.json() + const suggData = await suggRes.json() + setData(compData) + setReport(suggData) + const uniqueSuppliers = new Map() + for (const item of compData) { + for (const s of item.suppliers) { + if (!uniqueSuppliers.has(s.name)) { + uniqueSuppliers.set(s.name, { name: s.name, count: 0, timestamp: Date.now() }) + } + uniqueSuppliers.get(s.name)!.count++ + } + } + setSuppliers(Array.from(uniqueSuppliers.values())) + } catch { + // ignore + } finally { + setLoading(false) + } + } + + useEffect(() => { loadData() }, []) + + const handleFileUpload = async (file: File) => { + if (!supplierName.trim()) { + showToast('请先输入供应商名称') + return + } + if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { + showToast('请上传 Excel 或 CSV 文件') + return + } + setUploading(true) + try { + const form = new FormData() + form.append('file', file) + form.append('supplier', supplierName.trim()) + const res = await fetch(`${API}/upload`, { method: 'POST', body: form }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message || '上传失败') + } + const result = await res.json() + showToast(`成功上传 ${result.supplier} 的 ${result.count} 条报价`) + setSupplierName('') + await loadData() + } catch (e: unknown) { + const err = e as Error + showToast(err.message || '上传失败') + } finally { + setUploading(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + if (e.dataTransfer.files.length > 0) handleFileUpload(e.dataTransfer.files[0]) + } + + const sortedData = [...data].sort((a, b) => { + if (sortBy === 'price') return a.bestPrice - b.bestPrice + if (sortBy === 'savings') return b.savings - a.savings + return a.productName.localeCompare(b.productName, 'zh') + }) + + const allSupplierNames = [...new Set(data.flatMap(i => i.suppliers.map(s => s.name)))].filter(Boolean) + + if (loading) return
加载中...
+ + return ( +
+ {/* Header */} +
+
+

智能比价助手

+

多供应商报价自动解析对齐,AI 分析给出最优采购建议

+
+
+ {(['upload', 'compare', 'suggestion'] as Step[]).map((s, i) => ( + + ))} +
+
+ + {/* Step stats bar */} +
+ 已上传供应商: +
+ {suppliers.length === 0 ? ( + 暂无 + ) : suppliers.map(s => ( + + {s.name} + ({s.count}) + + ))} +
+ {data.length} 组商品待比价 +
+ + {/* Step 1: Upload */} + {step === 'upload' && ( +
+
+

+ + 上传供应商报价 +

+

输入供应商名称后,拖拽或选择该供应商的报价文件(Excel格式)

+
+ setSupplierName(e.target.value)} placeholder="输入供应商名称(如:远东电缆)" + className="flex-1 px-3 py-2 text-xs border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary" /> +
+
{ e.preventDefault(); setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + onClick={() => fileRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer ${ + dragOver ? 'border-secondary bg-blue-50' : 'border-border-subtle hover:border-secondary hover:bg-blue-50/30' + }`}> + { if (e.target.files?.[0]) handleFileUpload(e.target.files[0]) }} /> + +

{uploading ? '上传解析中...' : '拖拽文件或点击选择'}

+

支持 .xlsx / .xls / .csv 格式

+
+
+ +
+

+ + 已上传供应商 +

+ {suppliers.length === 0 ? ( +
暂无供应商数据,请先上传报价文件
+ ) : ( +
+ {suppliers.map(s => ( +
+
+
+ {s.name.charAt(0)} +
+
+
{s.name}
+
{s.count} 条报价
+
+
+
+ ))} + {data.length > 0 && ( + + )} +
+ )} +
+
+ )} + + {/* Step 2: Compare */} + {step === 'compare' && ( +
+
+

+ + 比价分析表格 +

+
+ 排序: + +
+
+ {sortedData.length === 0 ? ( +
暂无比价数据
+ ) : ( +
+ {allSupplierNames.length > 0 && ( +
+ + + + + + + {allSupplierNames.map(n => ( + + ))} + + + + + + {sortedData.filter(i => i.hasSupplier).map((item, i) => ( + + + + + {allSupplierNames.map(n => { + const supplier = item.suppliers.find(s => s.name === n) + const isBest = supplier && supplier.price === item.bestPrice + return ( + + ) + })} + + + + ))} + +
商品名称规格数量{n}最优价价差
{item.productName}{item.spec}{item.quantity} + {supplier ? ( + + {fmtPrice(supplier.price)} + + ) : ( + - + )} + {fmtPrice(item.bestPrice)} + {item.priceSpread > 0 ? ( + +{fmtPrice(item.priceSpread)} + ) : ( + - + )} +
+
+ )} + {sortedData.some(i => !i.hasSupplier) && ( +
+

其他商品(无供应商对比数据)

+
+ {sortedData.filter(i => !i.hasSupplier).map((item, i) => ( + + {item.productName} + ×{item.quantity} + {fmtPrice(item.bestPrice)} + + ))} +
+
+ )} +
+ )} +
+ )} + + {/* Step 3: Suggestion - 采购建议报告 */} + {step === 'suggestion' && ( +
+ {!report || report.totalItems === 0 ? ( +
暂无采购建议数据
+ ) : ( + <> + {/* Summary Hero */} +
+
+
+ +
+
+

采购建议报告

+

共分析 {report.totalItems} 个SKU,{report.supplierRanking?.length || 0} 家供应商报价

+
+
+
+
+
{fmtPrice(report.totalSplitCost)}
+
拆分采购总价
+
+
+
{fmtPrice(report.savingsVsSingle)}
+
预计节省金额
+
+
+
{report.savingsPctVsSingle || '0'}%
+
综合降本比例
+
+
+
+ + {/* Strategy cards */} +
+ {/* Single supplier */} +
+
+
A
+

方案一:统一采购

+
+

全部 SKU 从同一家供应商采购,管理成本最低

+
+
+ 推荐供应商:{report.bestSingleSupplier?.name || '-'} + 总价 {fmtPrice(report.bestSingleSupplier?.total)} +
+
+
+ {report.supplierRanking?.map((s: any, i: number) => ( +
+
+ {i + 1} + {s.name} +
+ {fmtPrice(s.total)} +
+ ))} +
+
+ + {/* Split by SKU */} +
+
+
B
+

方案二:拆分采购

+
+ + {/* Supplier count selector */} +
+
选择在几家供应商处下单:
+
+ {[2, 3, 4].map(k => { + const active = splitSupplierCount === k + return ( + + ) + })} +
+
+ + 系统会自动选择成本最低的供应商组合。如果少数几家供应商对所有商品都是最低价,选择更多家不会改变结果。 +
+
+ + {/* Result: unconstrained or constrained */} + {splitSupplierCount ? (() => { + const opt = report.splitByCount?.[splitSupplierCount] + if (!opt || !opt.bestSuppliers.length) return ( +
供应商数量不足,无法计算
+ ) + return ( + <> +
+
+ 最优方案:在 {opt.bestSuppliers.join('、')} 共 {opt.bestSuppliers.length} 家下单 +
+
+
+
{fmtPrice(opt.totalCost)}
+
拆分总价
+
+
+
-{fmtPrice(opt.savingsVsSingle)}
+
比方案一节省
+
+
+
{opt.savingsPct}%
+
降本比例
+
+
+ {opt.supplierBreakdown.map((sb: any) => ( +
+ {sb.name} + {sb.count}个SKU / {fmtPrice(sb.total)} +
+ ))} +
+ + {/* Dynamic bar chart for selected K */} +
+
{opt.bestSuppliers.length}家供应商分配SKU数:
+ {opt.supplierBreakdown.map((sb: any) => ( +
+
+
+ {sb.count}个 +
+
+ {sb.name} +
+ ))} +
+ + {/* Detail table */} + {opt.assignments?.length > 0 && ( +
+
{opt.bestSuppliers.length}家供应商拆分明细
+
+ + + + + + + + + + + + {opt.assignments.map((a: any, i: number) => ( + + + + + + + + ))} + +
商品名称分配供应商单价数量总价
{a.productName} + {a.supplier} + {fmtPrice(a.price)}{a.quantity}{fmtPrice(a.total)}
+
+
+ )} + + ) + })() : ( + <> + {/* Unconstrained split summary */} +
+
+
+
{fmtPrice(report.totalSplitCost)}
+
无限制拆分总价
+
+
+
-{fmtPrice(report.savingsVsSingle)}
+
比方案一节省
+
+
+
+ + {/* Unconstrained bar chart */} +
+
各供应商中标SKU数:
+ {(() => { + const counts = new Map() + for (const p of report.splitPicks || []) { + counts.set(p.bestSupplier, (counts.get(p.bestSupplier) || 0) + 1) + } + return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).map(([name, count]) => ( +
+
+
+ {count}个 +
+
+ {name} +
+ )) + })()} +
+ + )} +
+
+ + {/* Per-SKU breakdown */} +
+
+

各SKU最优供应商及价差明细

+
+
+ + + + + + + + + + + + + + + {report.splitPicks?.map((p: any, i: number) => ( + + + + + + + + + + + ))} + +
商品名称推荐供应商单价数量总价(¥)次优总价节省降本
{p.productName} + {p.bestSupplier} + {fmtPrice(p.bestPrice)}{p.bestQuantity}{fmtPrice(p.bestTotal)}{fmtPrice(p.secondTotal)}{fmtPrice(p.savings)} + {p.savingsPercent > 0 ? ( + {p.savingsPercent}% + ) : ( + - + )} +
+
+
+ + {/* Recommendations */} +
+
+ +

采购建议与提示

+
+
    + {report.recommendations?.map((r: string, i: number) => ( +
  • + {i + 1} + {r} +
  • + ))} +
+
+ + {/* Export */} +
+

导出报告

+
+ +
+
+ + )} +
+ )} + + {/* Toast */} + {toast && ( +
+ {toast} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/ImagePage.tsx b/apps/web/src/components/ImagePage.tsx new file mode 100644 index 0000000..ebecb73 --- /dev/null +++ b/apps/web/src/components/ImagePage.tsx @@ -0,0 +1,668 @@ +'use client' + +import { useEffect, useState, useRef, useCallback } from 'react' +import JSZip from 'jszip' +import { saveAs } from 'file-saver' +import { API_BASE } from '@/lib/apiBase' + +interface ImageRecord { + id: string + productName: string + url: string + status: string + style?: string + approved?: boolean + regenerating?: boolean +} + +interface ProductItem { + name: string + spec: string + category: string +} + +const STYLE_OPTIONS = [ + { value: 'white', label: '白底图', desc: '纯白背景,建材工业品电商主图' }, + { value: 'scene', label: '场景图', desc: '工地厂房实景,展示使用场景' }, + { value: 'realistic', label: '实拍风格', desc: '实物拍摄质感,高清细节' }, + { value: 'minimal', label: '简约风', desc: '纯色背景,专业产品展示' }, +] + +const SIZE_OPTIONS = [ + { value: '1:1', label: '1:1 方图' }, + { value: '4:3', label: '4:3 横图' }, + { value: '3:4', label: '3:4 竖图' }, + { value: '16:9', label: '16:9 宽图' }, +] + +const API = `${API_BASE}/api/images` + +export default function ImagePage() { + const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'done'>('all') + const [images, setImages] = useState([]) + const [loading, setLoading] = useState(true) + const [generating, setGenerating] = useState(false) + const [selectedStyle, setSelectedStyle] = useState('white') + const [selectedSize, setSelectedSize] = useState('1:1') + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [toast, setToast] = useState('') + const [previewUrl, setPreviewUrl] = useState(null) + + // Upload state + const [dragOver, setDragOver] = useState(false) + const [importFile, setImportFile] = useState(null) + const [productList, setProductList] = useState([]) + const [matchMap, setMatchMap] = useState>({}) + const [exporting, setExporting] = useState(false) + const fileInputRef = useRef(null) + + const doneImages = images.filter(i => i.status === 'done') + const matchedImages = images.filter(i => i.status === 'done' && Object.values(matchMap).includes(i.id)) + + const showToast = (msg: string) => { + setToast(msg) + setTimeout(() => setToast(''), 2500) + } + + useEffect(() => { + fetch(API) + .then(r => r.json()) + .then(setImages) + .finally(() => setLoading(false)) + }, []) + + const filtered = images.filter(img => { + if (activeTab === 'pending') return img.status !== 'done' && img.status !== 'replaced' + if (activeTab === 'done') return img.status === 'done' + return img.status !== 'replaced' // all tab 隐藏已替换的 + }) + + const toggleSelect = (id: string) => { + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const toggleAll = () => { + if (filtered.every(img => selectedIds.has(img.id))) { + setSelectedIds(prev => { + const next = new Set(prev) + filtered.forEach(img => next.delete(img.id)) + return next + }) + } else { + setSelectedIds(prev => { + const next = new Set(prev) + filtered.forEach(img => next.add(img.id)) + return next + }) + } + } + + // Upload & parse via backend Python script + const processFile = async (file: File) => { + if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { + showToast('请上传 Excel 或 CSV 文件') + return + } + setImportFile(file) + try { + const form = new FormData() + form.append('file', file) + const res = await fetch(`${API}/parse`, { method: 'POST', body: form }) + const data = await res.json() + if (!res.ok) { + showToast(data.message || data.error || `请求失败 (${res.status})`) + return + } + if (!Array.isArray(data) || data.length === 0) { + showToast('文件中未识别到商品数据,请确认表格包含商品名称列') + return + } + setProductList(data) + showToast(`成功解析 ${data.length} 条商品`) + } catch (e) { + showToast(`文件解析失败:${e instanceof Error ? e.message : '未知错误'}`) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + if (e.dataTransfer.files.length > 0) processFile(e.dataTransfer.files[0]) + } + + // Batch generate + const batchGenerate = async () => { + if (productList.length === 0) return + setGenerating(true) + for (const p of productList) { + await fetch(`${API}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productName: p.name, style: selectedStyle }), + }) + } + showToast(`已提交 ${productList.length} 个商品生图任务`) + setTimeout(async () => { + const res = await fetch(API) + const data = await res.json() + setImages(data) + setGenerating(false) + }, 2000) + } + + // Clear + const clearAll = async () => { + setImages([]) + setProductList([]) + setImportFile(null) + setMatchMap({}) + } + + // Approve: save to image library + const approveImage = async (id: string) => { + setImages(prev => prev.map(img => img.id === id ? { ...img, approved: true } : img)) + try { + const res = await fetch(`${API}/${id}/approve`, { method: 'POST' }) + if (!res.ok) throw new Error() + showToast('已加入图库,下次相同商品将优先使用') + } catch { + setImages(prev => prev.map(img => img.id === id ? { ...img, approved: false } : img)) + showToast('加入图库失败') + } + } + + // Regenerate: create new image + const regenerateImage = async (id: string) => { + setImages(prev => prev.map(img => img.id === id ? { ...img, regenerating: true } : img)) + try { + const res = await fetch(`${API}/${id}/regenerate`, { method: 'POST' }) + if (!res.ok) throw new Error() + const newImage = await res.json() + // Replace old image with new one in the list + setImages(prev => prev.map(img => img.id === id ? { ...newImage, regenerating: false } : img)) + showToast('正在重新生成...') + + // 持续轮询直到生成完成 + const pollUntilDone = async (newId: string, attempts: number) => { + if (attempts <= 0) return + await new Promise(r => setTimeout(r, 3000)) + try { + const refreshRes = await fetch(API) + const data = await refreshRes.json() + setImages(data) + const updated = data.find((img: ImageRecord) => img.id === newId) + if (updated && (updated.status === 'generating' || updated.status === 'done' || updated.status === 'failed')) { + if (updated.status === 'generating') { + pollUntilDone(newId, attempts - 1) + } else if (updated.status === 'done') { + showToast('重新生成完成') + } else { + showToast('重新生成失败,请重试') + } + } + } catch { /* ignore */ } + } + pollUntilDone(newImage.id, 20) + } catch { + setImages(prev => prev.map(img => img.id === id ? { ...img, regenerating: false } : img)) + showToast('重新生成失败') + } + } + + // Match: link images to products (selected → filtered, otherwise auto by name) + const matchProducts = () => { + const hasSelection = selectedIds.size > 0 + const map: Record = {} + for (const p of productList) { + const matched = images.find(i => { + if (i.status !== 'done') return false + if (hasSelection) return selectedIds.has(i.id) && i.productName === p.name + return i.productName === p.name + }) + if (matched) map[p.name] = matched.id + } + setMatchMap(map) + const count = Object.keys(map).length + showToast(`已匹配 ${count}/${productList.length} 个商品`) + } + + // Adjust match: cycle through available done images for this product + const adjustMatch = (productName: string) => { + const candidates = doneImages.filter(i => i.productName === productName) + if (candidates.length === 0) return + const current = matchMap[productName] + const idx = candidates.findIndex(i => i.id === current) + const next = candidates[(idx + 1) % candidates.length] + setMatchMap(prev => ({ ...prev, [productName]: next.id })) + } + + // Push matched products to ProductPage via localStorage + const pushToProducts = () => { + const matched = productList + .filter(p => matchMap[p.name]) + .map(p => { + const img = images.find(i => i.id === matchMap[p.name]) + return { name: p.name, spec: p.spec, category: p.category, unit: '个', price: 0, quantity: 1, imageUrl: img?.url || '' } + }) + if (matched.length === 0) { + showToast('暂无已匹配商品,请先点击"匹配商品"') + return + } + localStorage.setItem('pa_products_push', JSON.stringify(matched)) + showToast(`已推送 ${matched.length} 个商品到上架助手,请切换到商品上架页面查看`) + } + + // Export matched images as ZIP + const exportZip = useCallback(async () => { + const matched = productList.filter(p => matchMap[p.name]) + if (matched.length === 0) { + showToast('暂无已匹配商品,请先点击"匹配商品"') + return + } + setExporting(true) + try { + const zip = new JSZip() + for (const p of matched) { + const img = images.find(i => i.id === matchMap[p.name]) + if (img?.url) { + try { + const res = await fetch(img.url) + const blob = await res.blob() + const ext = img.url.split('.').pop() || 'jpg' + const safeName = p.name.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_') + zip.file(`${safeName}.${ext}`, blob) + } catch { /* skip failed downloads */ } + } + } + const blob = await zip.generateAsync({ type: 'blob' }) + saveAs(blob, '商品图片.zip') + showToast(`已导出 ${matched.length} 张图片`) + } catch (e) { + showToast(`导出失败:${e instanceof Error ? e.message : '请重试'}`) + } finally { + setExporting(false) + } + }, [matchMap, productList, images]) + + if (loading) return
加载中...
+ + return ( +
+ {/* Header */} +
+
+

商品生图助手

+

上传商品列表,AI 自动生成符合平台规格的高质量商品图片

+
+
+ +
+
+ + {/* Workflow Steps: Upload → Generate → Preview → Match → Export */} +
+ {['上传商品列表', 'AI 生成图片', '预览审核', '匹配商品', '打包导出'].map((step, i) => ( + + {i + 1} + {step} + {i < 4 && →} + + ))} +
+ + {/* Step 1: Upload */} +
+
+

+ + 上传商品列表 +

+
+
+ {!importFile ? ( +
{ e.preventDefault(); setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + className={`border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer hover:border-secondary hover:bg-blue-50/30 ${dragOver ? 'border-secondary bg-blue-50' : 'border-border-subtle'}`} + onClick={() => fileInputRef.current?.click()}> + { if (e.target.files?.[0]) processFile(e.target.files[0]) }} /> + +

拖拽 Excel/CSV 文件到此处,或 点击选择文件

+

支持 .xlsx, .xls, .csv 格式,自动解析商品名称和规格

+
+ ) : ( +
+
+
+ +
+
{importFile.name}
+
已解析 {productList.length} 条商品
+
+
+ +
+
+ + + + + + + + + + + {productList.map((p, i) => ( + + + + + + + ))} + +
#商品名称规格分类
{i + 1}{p.name}{p.spec}{p.category}
+
+
+ )} +
+
+ + {/* Step 2: Generate Settings & Action */} +
+
+

+ + AI 生成设置 +

+
+
+
+ {/* Style Selection */} +
+

图片风格

+
+ {STYLE_OPTIONS.map(s => ( + + ))} +
+
+ {/* Size Selection */} +
+

图片尺寸

+
+ {SIZE_OPTIONS.map(s => ( + + ))} +
+
+
+
+
+ 已选 {productList.length} 个商品 · 风格: {STYLE_OPTIONS.find(s => s.value === selectedStyle)?.label} · 尺寸: {SIZE_OPTIONS.find(s => s.value === selectedSize)?.label} +
+ +
+
+
+ + {/* Step 3-5: Preview & Results */} +
+
+
+

+ + 图片预览 / 审核 +

+
+ {(['all', 'pending', 'done'] as const).map(tab => ( + + ))} +
+
+
+ + +
+
+
+ {filtered.length === 0 ? ( +
+ +

暂无图片数据

+

上传商品列表后点击「批量 AI 生图」开始生成

+
+ ) : ( +
+
+ 0 && filtered.every(img => selectedIds.has(img.id))} + onChange={toggleAll} /> + 全选 + 已选 {selectedIds.size} 张 +
+
+ {filtered.map(img => ( +
+
+ toggleSelect(img.id)} /> +
+
+ {img.status === 'generating' || img.regenerating ? ( +
+
⏳
+
{img.regenerating ? '重新生成中...' : '生成中...'}
+
+ ) : img.status === 'failed' ? ( +
+
❌
+
生成失败
+
+ ) : img.url ? ( + {img.productName} setPreviewUrl(img.url)} /> + ) : ( +
无图片
+ )} +
+
+

{img.productName}

+
+ + {img.approved ? '已使用' : img.status === 'done' ? '已完成' : img.status === 'replaced' ? '已替换' : img.status === 'failed' ? '失败' : '生成中'} + +
+ {/* 审核按钮:仅已完成状态显示 */} + {img.status === 'done' && !img.approved && !img.regenerating && ( +
+ + +
+ )} + {/* 失败状态:重新生成按钮 */} + {img.status === 'failed' && !img.regenerating && ( +
+ +
+ )} + {/* 已使用状态显示 */} + {img.approved && ( +
+ 已加入图库 +
+ )} +
+
+ ))} +
+
+ )} +
+
+ + {/* Match & Export Section */} +
+
+

+ + 商品-图片匹配 +

+

已自动匹配 {productList.length} 个商品与生成图片,可手动调整对应关系。

+ {productList.length > 0 ? ( +
+ + + + + + + + + + {productList.map((p, i) => { + const matchedId = matchMap[p.name] + const matched = matchedId ? images.find(img => img.id === matchedId) : images.find(img => img.productName === p.name && img.status === 'done') + return ( + + + + + + ) + })} + +
商品名称匹配图片操作
{p.name} + {matched ? ( +
+
setPreviewUrl(matched.url)}> + +
+ 已匹配 +
+ ) : ( + 待生成 + )} +
+ +
+
+ ) : ( +

暂无商品数据,请先上传商品列表

+ )} +
+
+

+ + 打包导出 +

+

将已匹配的商品和图片打包,推送至商品上架助手或导出为压缩包。

+
+ + +
+
+
+ + {/* Preview Lightbox */} + {previewUrl && ( +
setPreviewUrl(null)}> +
e.stopPropagation()}> + 预览 + +
+
+ )} + + {/* Toast */} + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/InquiryPage.tsx b/apps/web/src/components/InquiryPage.tsx new file mode 100644 index 0000000..94435fc --- /dev/null +++ b/apps/web/src/components/InquiryPage.tsx @@ -0,0 +1,899 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { API_BASE } from '@/lib/apiBase' + +interface Lead { + id: string + title: string + star: number + level: string + amount: string + status: string + deadline: string + publish: string + publishTime?: string + cat: string + org: string + region: string + regionDetail?: string + source: string + detailUrl: string + contact: string + phone: string + summary: string + reason: string[] + quantity?: number | null + unit?: string + attachments?: string +} + +const API = `${API_BASE}/api/leads` + +function mapLead(raw: any): Lead { + return { + id: raw.id, + title: raw.title, + star: raw.star || 0, + level: raw.level || 'C', + amount: raw.amount || '待确认', + status: raw.status || '待复核', + deadline: raw.deadline || '', + publish: raw.publish, + publishTime: raw.publishTime, + cat: raw.category || '', + org: raw.organization || '', + region: raw.region || '', + regionDetail: raw.regionDetail || '', + source: raw.source || '', + detailUrl: raw.detailUrl || '', + contact: raw.contact || '', + phone: raw.phone || '', + summary: raw.summary || '', + reason: raw.reason ? (typeof raw.reason === 'string' ? [raw.reason] : raw.reason) : [], + quantity: raw.quantity ?? null, + unit: raw.unit || '', + attachments: raw.attachments || '', + } +} + +const STATUS_OPTIONS = ['全部状态', '待复核', '待跟进', '跟进中', '已转询价', '已转协同', '已成交', '无效线索'] +const LEVEL_OPTIONS = ['全部级别', 'S级', 'A级', 'B级'] +const CAT_OPTIONS = ['全部品类', '防火门', '配电箱', '电线电缆', '阀门', '钢材', '涂料', '混凝土'] +const SOURCE_OPTIONS = ['全部来源', 'PowerChina 招采', '新矿集团', '发改委审批', '行业资讯'] +const PERSON_OPTIONS = ['全部人员', '郑钊', '赵主任', '运营管理员', '未分配'] + +function getStatusClass(status: string) { + const map: Record = { + '待跟进': 'bg-rose-50 text-rose-700', + '跟进中': 'bg-blue-50 text-blue-700', + '已转询价': 'bg-emerald-50 text-emerald-700', + '已成交': 'bg-emerald-50 text-emerald-700', + '无效线索': 'bg-slate-100 text-slate-500', + '待复核': 'bg-amber-50 text-amber-700', + '已转协同': 'bg-purple-50 text-purple-700', + } + return map[status] || 'bg-amber-50 text-amber-700' +} + +function getLevelClass(level: string) { + const map: Record = { + 'S级': 'bg-blue-50 text-blue-700 border-blue-200', + 'A级': 'bg-amber-50 text-amber-700 border-amber-200', + 'B级': 'bg-slate-100 text-slate-600 border-slate-200', + } + return map[level] || 'bg-slate-100 text-slate-600 border-slate-200' +} + +export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: string) => void }) { + const [allLeads, setAllLeads] = useState([]) + const [leads, setLeads] = useState([]) + const [filtered, setFiltered] = useState([]) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [drawerLead, setDrawerLead] = useState(null) + const [showSModal, setShowSModal] = useState(false) + const [showPubModal, setShowPubModal] = useState(false) + const [pubLoading, setPubLoading] = useState(false) + const [publishFailModal, setPublishFailModal] = useState<{ + failed: Array<{ title: string; error: string }> + success: number + } | null>(null) + const [batchModal, setBatchModal] = useState(null) + const [batchAssignee, setBatchAssignee] = useState('郑钊') + const [batchStatus, setBatchStatus] = useState('跟进中') + const [batchLoading, setBatchLoading] = useState(false) + const [batchNote, setBatchNote] = useState('') + const [toast, setToast] = useState('') + const pageSize = 10 + + const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) } + + const [fTime, setFTime] = useState('全部时间') + const [fStar, setFStar] = useState('全部星级') + const [fStatus, setFStatus] = useState('全部状态') + const [fLevel, setFLevel] = useState('全部级别') + const [fCat, setFCat] = useState('全部品类') + const [fSource, setFSource] = useState('全部来源') + const [fPerson, setFPerson] = useState('全部人员') + + const loadLeads = useCallback(async (star?: string, status?: string, level?: string, cat?: string, source?: string, person?: string) => { + setLoading(true) + const params = new URLSearchParams() + if (star && star !== '全部星级') { + if (star === '5星') params.set('star', '5') + else if (star === '4星') params.set('star', '4') + else if (star === '3星') params.set('star', '3') + else if (star === '2星及以下') params.set('star', '2星及以下') + } + if (status && status !== '全部状态') params.set('status', status) + if (level && level !== '全部级别') params.set('level', level) + if (cat && cat !== '全部品类') params.set('category', cat) + if (source && source !== '全部来源') params.set('source', source) + if (person && person !== '全部人员' && person !== '未分配') params.set('assignedTo', person) + if (person === '未分配') params.set('assignedTo', '未分配') + try { + const qs = params.toString() + const data: any[] = await fetch(`${API}${qs ? '?' + qs : ''}`).then(r => r.json()) + const mapped = data.map(mapLead) + setAllLeads(mapped) + setLeads(mapped) + setFiltered(mapped) + } catch { showToast('加载线索列表失败') } + setLoading(false) + }, []) + + useEffect(() => { loadLeads(fStar, fStatus, fLevel, fCat, fSource, fPerson) }, [loadLeads, fStar, fStatus, fLevel, fCat, fSource, fPerson]) + + const refreshLeads = async () => { + await loadLeads(fStar, fStatus, fLevel, fCat, fSource, fPerson) + } + + const totalPages = Math.ceil(filtered.length / pageSize) + const pageLeads = filtered.slice((page - 1) * pageSize, page * pageSize) + + const toggleSelect = (id: string) => { + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const toggleAll = () => { + if (pageLeads.every(l => selectedIds.has(l.id))) { + setSelectedIds(prev => { + const next = new Set(prev) + pageLeads.forEach(l => next.delete(l.id)) + return next + }) + } else { + setSelectedIds(prev => { + const next = new Set(prev) + pageLeads.forEach(l => next.add(l.id)) + return next + }) + } + } + + const exportCSV = () => { + const exportList = filtered.filter(l => selectedIds.has(l.id)) + if (exportList.length === 0) return + const csv = '线索ID,项目名称,星级,甲方级别,金额,状态,投标截止,发布状态,品类,甲方单位,地区\n' + + exportList.map(l => `${l.id},${l.title},${l.star}星,${l.level},${l.amount},${l.status},${l.deadline},${l.publish},${l.cat},${l.org},${l.region}`).join('\n') + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = `线索导出_${new Date().toISOString().slice(0, 10)}.csv` + link.click() + } + + const oneClickPublish = () => { + const selected = filtered.filter(l => selectedIds.has(l.id)) + if (selected.length === 0) return + setShowPubModal(true) + } + + const showPublishFailModal = ( + failed: Array<{ title: string; error: string }>, + success: number, + ) => { + setPublishFailModal({ failed, success }) + } + + const doPublish = async () => { + const selected = filtered.filter(l => selectedIds.has(l.id)) + if (selected.length === 0) return + setPubLoading(true) + try { + let success = 0 + const failed: Array<{ title: string; error: string }> = [] + for (const lead of selected) { + try { + const res = await fetch(`${API}/${lead.id}/publish`, { method: 'POST' }) + if (res.ok) { + lead.publish = '已发布' + success++ + } else { + const errBody = await res.json().catch(() => ({})) + const errMsg = + errBody.message || `HTTP ${res.status} ${res.statusText}` || '未知错误' + failed.push({ title: lead.title, error: errMsg }) + } + } catch (e) { + failed.push({ + title: lead.title, + error: e instanceof Error ? e.message : '网络错误', + }) + } + } + setFiltered([...filtered]) + if (failed.length === 0) { + setSelectedIds(new Set()) + setShowPubModal(false) + showToast(`发布完成:成功 ${success} 条`) + } else { + showPublishFailModal(failed, success) + } + } catch (e: unknown) { + const err = e as Error + showToast(err.message) + } finally { + setPubLoading(false) + } + } + + const selectedList = filtered.filter(l => selectedIds.has(l.id)) + + const checkSelected = (): boolean => { + if (selectedList.length === 0) { + showToast('请先勾选线索') + return false + } + return true + } + + const batchToRFQ = async () => { + if (!checkSelected()) return + setBatchLoading(true) + try { + let success = 0, fail = 0 + for (const lead of selectedList) { + try { + const res = await fetch(`${API}/${lead.id}/to-rfq`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assignee: batchAssignee, note: batchNote || '批量转询价' }), + }) + if (res.ok) success++ + else fail++ + } catch { fail++ } + } + showToast(`转询价完成:成功 ${success} 条${fail ? `,失败 ${fail} 条` : ''}`) + setBatchModal(null) + setSelectedIds(new Set()) + await refreshLeads() + } catch (e: unknown) { + showToast((e as Error).message) + } finally { + setBatchLoading(false) + } + } + + const batchToCollab = async () => { + if (!checkSelected()) return + setBatchLoading(true) + try { + let success = 0, fail = 0 + for (const lead of selectedList) { + try { + const res = await fetch(`${API}/${lead.id}/to-collab`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assignee: batchAssignee, note: batchNote || '批量转协同' }), + }) + if (res.ok) success++ + else fail++ + } catch { fail++ } + } + showToast(`转协同完成:成功 ${success} 条${fail ? `,失败 ${fail} 条` : ''}`) + setBatchModal(null) + setSelectedIds(new Set()) + await refreshLeads() + } catch (e: unknown) { + showToast((e as Error).message) + } finally { + setBatchLoading(false) + } + } + + const confirmBatchAssign = async () => { + setBatchLoading(true) + try { + let success = 0, fail = 0 + for (const lead of selectedList) { + try { + const res = await fetch(`${API}/${lead.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assignedTo: batchAssignee }), + }) + if (res.ok) success++ + else fail++ + } catch { fail++ } + } + showToast(`分配负责人:成功 ${success} 条${fail ? `,失败 ${fail} 条` : ''}`) + setBatchModal(null) + setSelectedIds(new Set()) + await refreshLeads() + } catch (e: unknown) { + showToast((e as Error).message) + } finally { + setBatchLoading(false) + } + } + + const confirmBatchStatus = async () => { + setBatchLoading(true) + try { + let success = 0, fail = 0 + for (const lead of selectedList) { + try { + const res = await fetch(`${API}/${lead.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: batchStatus }), + }) + if (res.ok) success++ + else fail++ + } catch { fail++ } + } + showToast(`变更状态:成功 ${success} 条${fail ? `,失败 ${fail} 条` : ''}`) + setBatchModal(null) + setSelectedIds(new Set()) + await refreshLeads() + } catch (e: unknown) { + showToast((e as Error).message) + } finally { + setBatchLoading(false) + } + } + + const sLeads = allLeads.filter(l => l.level === 'S级') + const sSorted = [...sLeads].sort((a, b) => { + if (a.status === '待跟进' && b.status !== '待跟进') return -1 + if (a.status !== '待跟进' && b.status === '待跟进') return 1 + return a.deadline.localeCompare(b.deadline) + }) + + return ( +
+ {/* Header */} +
+
+

询价发布助手

+

集中筛选、批量动作、快速发布询价

+
+
+ + +
+
+ + {/* Filter Toolbar */} +
+
+ {[ + { label: '时间范围', value: fTime, set: setFTime, opts: ['全部时间', '今天', '昨天', '本周', '本月'] }, + { label: '星级', value: fStar, set: setFStar, opts: ['全部星级', '5星', '4星', '3星', '2星及以下'] }, + { label: '状态', value: fStatus, set: setFStatus, opts: STATUS_OPTIONS }, + { label: '甲方级别', value: fLevel, set: setFLevel, opts: LEVEL_OPTIONS }, + { label: '品类', value: fCat, set: setFCat, opts: CAT_OPTIONS }, + { label: '来源平台', value: fSource, set: setFSource, opts: SOURCE_OPTIONS }, + { label: '负责人', value: fPerson, set: setFPerson, opts: PERSON_OPTIONS }, + ].map(f => ( +
+ {f.label} + +
+ ))} +
+
+ 批量操作: + {[ + { name: '分配负责人', desc: '批量指定跟进人', icon: 'M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z', action: () => checkSelected() && setBatchModal('assign') }, + { name: '转询价', desc: '批量生成询价单', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', action: () => checkSelected() && setBatchModal('rfq') }, + { name: '转协同', desc: '批量转协同方处理', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z', action: () => checkSelected() && setBatchModal('collab') }, + { name: '变更状态', desc: '批量修改状态', icon: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z', action: () => checkSelected() && setBatchModal('status') }, + ].map(b => ( +
+ +
+
{b.name}
+
{b.desc}
+
+
+
+ ))} +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + + + + + + {loading ? ( + + ) : pageLeads.length === 0 ? ( + + ) : pageLeads.map(lead => { + const stars = '★'.repeat(lead.star) + '☆'.repeat(5 - lead.star) + return ( + setDrawerLead(lead)}> + + + + + + + + + + + + + ) + })} + +
+ 0 && pageLeads.every(l => selectedIds.has(l.id))} onChange={toggleAll} /> + 项目名称来源星级甲方级别金额(万)数量状态投标截止公告日期发布状态
加载中...
暂无数据
e.stopPropagation()}> + toggleSelect(lead.id)} /> + +
{lead.title}
+
线索ID: {lead.id}
+
+ {lead.source || '-'} + {stars}{lead.level} + {lead.amount} + {lead.quantity ? `${lead.quantity}${lead.unit || ''}` : '-'}{lead.status}{lead.deadline ? lead.deadline.slice(5, 10) : '-'}{lead.publishTime ? lead.publishTime.slice(0, 10) : '-'} + {lead.publish || '未发布'} +
+ {/* Pagination */} + {filtered.length > pageSize && ( +
+ + 第 {filtered.length > 0 ? (page - 1) * pageSize + 1 : 0} - {Math.min(page * pageSize, filtered.length)} 条,共 {filtered.length} 条 + +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 2).map((p, idx, arr) => ( + + {idx > 0 && arr[idx - 1] !== p - 1 && …} + + + ))} + +
+
+ )} +
+ + {/* Bento Context Cards */} +
+
+
+ +
+

线索流转速度

+
+
+ {[ + { label: '抓取 → 研判', sub: '(自动流程)', pct: 90, time: '0.5h', color: 'bg-secondary' }, + { label: '研判 → 待跟进', sub: '(规则引擎分发)', pct: 70, time: '2.3h', color: 'bg-blue-600' }, + { label: '待跟进 → 转询价', sub: '(业务员处理)', pct: 45, time: '18.5h', color: 'bg-amber-500' }, + ].map(item => ( +
+
+ {item.label} {item.sub} + {item.time} +
+
+
+
+
+ ))} +
+
+
+
12.8%
+
待跟进 → 成交 转化率
+
近30天统计
+
+
+
+
setShowSModal(true)}> +

+ + S级甲方动态 +

+
+ {sSorted.slice(0, 2).map(l => ( +
+

{l.status === '待跟进' ? '待跟进' : l.status === '已转询价' ? '已转询价' : '跟进中'}

+

{l.title}

+

{l.org} · {l.level} · {l.star}星

+
+ ))} +
+
+
+ + {/* Lead Preview Drawer */} + {drawerLead && ( + <> +
setDrawerLead(null)} /> +
+
+
+
{'★'.repeat(drawerLead.star)}{'☆'.repeat(5 - drawerLead.star)}
+

{drawerLead.title}

+

线索ID: {drawerLead.id} · 状态: {drawerLead.status}

+
+ +
+
+
+ {[ + { label: '预估金额', value: drawerLead.amount }, + { label: '甲方级别', value: drawerLead.level }, + { label: '所属品类', value: drawerLead.cat }, + { label: '询价数量', value: drawerLead.quantity ? `${drawerLead.quantity}${drawerLead.unit || ''}` : '-' }, + { label: '地区', value: drawerLead.region }, + { label: '收货地址', value: drawerLead.regionDetail || '-' }, + { label: '甲方单位', value: drawerLead.org }, + { label: '投标截止', value: drawerLead.deadline, highlight: true }, + { label: '公告日期', value: drawerLead.publishTime || '-' }, + { label: '来源平台', value: drawerLead.source }, + { label: '来源链接', value: drawerLead.detailUrl, link: !!drawerLead.detailUrl }, + { label: '联系人', value: `${drawerLead.contact} · ${drawerLead.phone}` }, + ].map(f => { + const extra: Record = {} + if (f.highlight) extra.className = 'text-rose-600' + return ( +
+
{f.label}
+ {f.link ? ( + {f.value} + ) : ( +
{f.value}
+ )} +
+ ) + })} +
+
+
线索摘要
+

{drawerLead.summary}

+
+
+
研判依据
+
+ {drawerLead.reason.map((r, i) => ( +
+ {r} +
+ ))} +
+
+
+
发布状态
+

{drawerLead.publish || '未发布'}

+
+
+
公告日期
+

{drawerLead.publishTime || '-'}

+
+
+ + +
+
+
+ + )} + + {/* S级甲方动态 Modal */} + {showSModal && ( +
setShowSModal(false)}> +
e.stopPropagation()}> +
+

+ + S级甲方动态 +

+ +
+
+
+ + 近7日动态 · 按时间倒序排列 +
+
+ {sSorted.map(l => ( +
+
+
+ +
+
{l.title}
+
{l.org} · {l.cat}
+
+
+
+ {l.level} +
截止 {l.deadline}
+
+
+
+ {l.status} + {'★'.repeat(l.star)}{'☆'.repeat(5 - l.star)} +
+
+ ))} +
+
+
+
+ )} + + {/* 一键发布 Modal */} + {showPubModal && (() => { + const lead = filtered.find(l => selectedIds.has(l.id)) + if (!lead) return null + return ( +
{ if (!pubLoading) setShowPubModal(false) }}> +
e.stopPropagation()}> +
+

一键发布 direv

+ +
+
+
即将发布以下线索至 direv.com.cn:
+
+ {lead.title}
+ 品类:{lead.cat} | 数量:待确认 | 截止:{lead.deadline}
+ 联系:郑钊 15532027557 +
+
将自动匹配联系人、收货地址等信息后发布。
+
+ + +
+
+
+
+ ) + })()} + + {/* 发布失败 Modal */} + {publishFailModal && ( +
setPublishFailModal(null)}> +
e.stopPropagation()}> +
+
+

+ + + + 发布失败 +

+

+ 以下 {publishFailModal.failed.length} 条线索未成功发布到 B2B 平台 + {publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''} + ,请检查 MCP 对接配置后重试。 +

+
+ +
+
+ {publishFailModal.failed.map((f, i) => ( +
+
{f.title}
+
{f.error}
+
+ ))} +
+ +
+
+ )} + + {/* 分配负责人 Modal */} + {batchModal === 'assign' && ( +
{ if (!batchLoading) setBatchModal(null) }}> +
e.stopPropagation()}> +
+

分配负责人

+ +
+
+
将以下 {selectedList.length} 条线索分配给:
+ +
+ + +
+
+
+
+ )} + + {/* 批量转询价 Modal */} + {batchModal === 'rfq' && ( +
{ if (!batchLoading) setBatchModal(null) }}> +
e.stopPropagation()}> +
+

批量转询价

+ +
+
+
即将为以下 {selectedList.length} 条线索创建询价单:
+
+ {selectedList.map(l =>
· {l.title}
)} +
+
+ 指派负责人 + +
+
+ 备注 +