feat(ui): 前端 UI 组件化重构 + 发布表单增强
【新组件】 - ErrorBoundary: 全局错误边界,渲染崩溃时显示重试/刷新按钮 - ui/Toast: 全局 Toast 通知系统 (success/error/warning/info),Context Provider 模式 - ui/Modal: 统一弹窗 (ESC关闭 + loading遮罩 + 自定义footer) - ui/Skeleton: 骨架屏 (SkeletonTable / SkeletonCard) - ui/Lightbox: 图片放大灯箱 【页面迁移】 - InquiryPage: useToast + Modal + SkeletonTable 替换手写代码,新增 pubForm 发布前预填联系人/电话/地址 + 发布时传 body 给后端 - LeadDetailPage: 迁移到 Toast + Modal - ProductPage: 迁移到 Toast + Modal + SkeletonTable - AdminPage: 迁移到 useToast - ComparisonPage: 迁移到 Toast + SkeletonTable - ImagePage: 迁移到 Toast + SkeletonCard 【基础设施】 - globals.css: 新增 fadeIn/slideUp 动画 - page.tsx: SPA 根接入 ToastProvider + ErrorBoundary - tailwind.config.ts: 新增 animate keyframes 【后端】 - integration.service.ts: MCP session 管理 + charset 修复 - lead.controller.ts/lead.service.ts: 发布端点增强 + 物料提取服务注入
This commit is contained in:
parent
4d5521e365
commit
68f6fc5c16
178
AGENTS.md
178
AGENTS.md
@ -85,10 +85,13 @@ 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 (Node.js 版 `product_network_mcp`) | `ssh devbox` + PM2, **不在 Mac 本地** |
|
||||
| **8095** | devbox MCP server (Java 版 `industry-mcp-server` Docker) | `ssh devbox` + `docker ps` 看 `industry-mcp-server`, **不在 Mac 本地** |
|
||||
|
||||
注意:**3002 是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
|
||||
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002)。
|
||||
注意:**3002 / 8095 都是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
|
||||
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002/8095)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用。
|
||||
|
||||
> ⚠️ 2026-07-15 实测: 平台助手本地 `next dev` 偶尔会 fallback 到 3000 端口(被 `admin_new` Vite 项目抢 3001 时),以实际启动后终端打印的端口为准。`AdminPage.tsx:1000` 默认填的 endpoint 仍是 `http://100.111.84.16:3002/mcp`(Node 版)。
|
||||
|
||||
AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。
|
||||
|
||||
@ -122,7 +125,7 @@ AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP serv
|
||||
| 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` |
|
||||
| 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 |
|
||||
@ -172,8 +175,8 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
|
||||
|
||||
- endpoint:`https://api.minimaxi.com/v1/chat/completions`
|
||||
- model:`MiniMax-M2.7`(env `MINIMAX_MODEL` 可覆盖)
|
||||
- env:`MINIMAX_API_KEY` 写入 `apps/api/.env`(生产已配)
|
||||
- **注意**:M2.7 是推理模型,max_tokens 要 ≥500 才够(否则全被 <think> 吃掉)
|
||||
- env:`MINIMAX_API_KEY` 写入 `apps/api/.env`(生产已配)。本地开发 `.env` 也需要配,否则物料提取/category-inference 走兜底
|
||||
- **注意**:M2.7 是推理模型,max_tokens 要 ≥500 才够(否则全被 `<think>` 吃掉)。物料提取正文用 2000,标题提取用 800
|
||||
- 生产 `.env` 同时保留 `MINIMAX_API_KEY` 和 `MINIMAX_MODEL` 两个变量
|
||||
|
||||
### 历史回填策略(重要:AI 不可全信)
|
||||
@ -233,6 +236,7 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
|
||||
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/` 下)
|
||||
- ⚠️ **Web 构建注意**:本地 build 的 `.next/` 若直接 rsync 到生产,可能因 `node_modules` 版本不一致导致 chunk 加载失败。遇此情况,在生产服务器上执行 `cd /opt/pingtai/apps/web && npx next build` 重建。
|
||||
- **常见操作**:
|
||||
- `pm2 list` / `pm2 logs` / `pm2 restart pingtai-api`
|
||||
- `tail -f /var/log/pingtai/api.out-1.log`
|
||||
@ -252,6 +256,7 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
|
||||
| `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 | 通用化采集器调度 |
|
||||
| `MaterialExtractorService` | class | apps/api/src/lead/material-extractor.service.ts | **W37 新增** 发布时实时提取物料清单 (items),三级降级链 |
|
||||
| `search_image.py` | script | scripts/search_image.py | Bing 搜索 top N 候选 |
|
||||
| `process_image.py` | script | scripts/process_image.py | 去水印+裁剪+白底合成 |
|
||||
|
||||
@ -262,21 +267,34 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
|
||||
```
|
||||
平台助手 NestJS (4000)
|
||||
↓ IntegrationService.callTool()
|
||||
↓ MCP 协议 (Streamable HTTP)
|
||||
↓ MCP 协议 (Streamable HTTP, POST 方法)
|
||||
↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key)
|
||||
renren-mcp-server (Node.js, devbox:3002)
|
||||
renren-mcp-server
|
||||
├─ Node.js 版 (devbox:3002, product_network_mcp)
|
||||
└─ Java 版 (devbox:8095, industry-mcp-server Docker 容器)
|
||||
↓ 直调 Java 业务方法
|
||||
↓
|
||||
product_network 7 个 Java 服务
|
||||
```
|
||||
|
||||
注: MCP server 端口 3002 是 **devbox 开发机** 上的端口, 不是平台助手本地端口 (3001/4000)。
|
||||
注: MCP server 端口 **3002(Node)和 8095(Java Docker)都是 devbox 开发机上的端口**, 不是平台助手本地端口 (3001/4000)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用, 申请时确认走哪一套。
|
||||
|
||||
### MCP 端点要求(重要!)
|
||||
|
||||
平台助手消费 MCP 时, 端点必须满足:
|
||||
|
||||
- **协议**: Streamable HTTP (`@modelcontextprotocol/sdk` 的 `StreamableHTTPClientTransport`)
|
||||
- **方法**: **POST** (不是 GET). 如果 MCP server 实现要求 GET, 需要改 server 而不是 client
|
||||
- **认证头**: 多租户 APIKey → `x-app-id` + `x-app-secret`, 由 devbox B2B 运营后台"集成管理 → 应用列表"申请
|
||||
- **路径示例**: `http://host:port/mcp` (注意末尾 `/mcp`)
|
||||
|
||||
前端 "MCP 对接配置 → 新增对接" 弹窗的 MCP 地址字段下方有提示文案提醒这些要求。
|
||||
|
||||
### 设计原则
|
||||
|
||||
- **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4)
|
||||
- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销)
|
||||
- **APIKey 认证**:Header `x-api-key`,AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex)
|
||||
- **APIKey 认证**:Header `x-app-id` + `x-app-secret`(多租户, 替代旧的 `x-api-key`), AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex)
|
||||
- **公司账号代理**:每客户 1 个 `sys_user` (user_type=99)
|
||||
|
||||
### 关键代码路径
|
||||
@ -284,11 +302,15 @@ product_network 7 个 Java 服务
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `apps/api/prisma/schema.prisma` | `Integration` 表(MCP 连接配置) |
|
||||
| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool) |
|
||||
| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool)+ **session 管理 + charset 修复** |
|
||||
| `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 对接配置(嵌入系统配置页面) |
|
||||
| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool + **可选 overrides 参数** |
|
||||
| `apps/api/src/lead/lead.controller.ts` | `POST /api/leads/:id/publish` 新增可选 body |
|
||||
| `apps/api/src/lead/material-extractor.service.ts` | **W37 新增** 物料清单提取(发布时实时调 MiniMax) |
|
||||
| `apps/api/src/lead/lead.module.ts` | 注册 `MaterialExtractorService` |
|
||||
| `apps/web/src/components/InquiryPage.tsx` | **发布前确认弹窗**(对接文档 §5.2) |
|
||||
| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面)+ MCP 协议提示文案 |
|
||||
|
||||
### 使用流程
|
||||
|
||||
@ -304,13 +326,50 @@ product_network 7 个 Java 服务
|
||||
const client = new Client({ name: 'platform-assistant', version: '1.0.0' })
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(endpoint),
|
||||
{ requestInit: { headers: { 'x-api-key': apiKey } } }
|
||||
{
|
||||
requestInit: {
|
||||
headers: { 'x-app-id': appId, 'x-app-secret': appSecret },
|
||||
},
|
||||
// 自定义 fetch: 自动管理 mcp-session-id (MCP Streamable HTTP 协议要求)
|
||||
fetch: this.buildSessionAwareFetch(id),
|
||||
}
|
||||
)
|
||||
await client.connect(transport)
|
||||
const tools = await client.listTools()
|
||||
await client.callTool({ name: 'publish_rfq', arguments: { title, ... } })
|
||||
```
|
||||
|
||||
### MCP Session 管理(2026-07-15 实现)
|
||||
|
||||
`integration.service.ts` 的 `buildSessionAwareFetch(integrationId)` 方法实现三层 session 管理:
|
||||
|
||||
1. **提取**:自定义 fetch wrapper 从 initialize 响应头 `mcp-session-id` 提取并缓存到 `mcpSessionIds: Map<integrationId, sessionId>`
|
||||
2. **注入**:后续所有请求自动在 header 中带 `mcp-session-id`
|
||||
3. **清理**:disconnect / 重连时自动清空旧 session
|
||||
|
||||
双保险设计(SDK 1.27.0 内置 session 管理 + 自定义 wrapper fetch 兜底):
|
||||
|
||||
| 路径 | 谁管理 session |
|
||||
|---|---|
|
||||
| connect() initialization | wrapper fetch 从响应头提取 |
|
||||
| listTools() / callTool() | wrapper fetch 注入 `mcp-session-id` header |
|
||||
| executeTool() 自动重连 | `clearSession(id)` 后再调用 `connect()` |
|
||||
| disconnect() | 调用 `clearSession(id)` |
|
||||
| 进程关闭 (onModuleDestroy) | `mcpSessionIds.clear()` |
|
||||
|
||||
### Content-Type charset 修复(2026-07-15)
|
||||
|
||||
SDK 默认设 `content-type: application/json` 但不带 `charset=utf-8`。Java Jackson 解析器缺 charset 时可能不以 UTF-8 解码,导致多字节中文的字节计数与 Content-Length 不匹配,报 JSON 解析错误。
|
||||
|
||||
**修复**:在 `buildSessionAwareFetch` wrapper 中检测 Content-Type,自动补上 `charset=utf-8`:
|
||||
|
||||
```typescript
|
||||
const ct = headers.get('content-type')
|
||||
if (ct && !ct.includes('charset') && ct.includes('application/json')) {
|
||||
headers.set('content-type', 'application/json; charset=utf-8')
|
||||
}
|
||||
```
|
||||
|
||||
### API 端点
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
@ -333,6 +392,97 @@ await client.callTool({ name: 'publish_rfq', arguments: { title, ... } })
|
||||
- 连接状态存储在数据库,服务重启后需重新连接
|
||||
- 发布失败降级为本地模式(生成假 inquiryNo)
|
||||
|
||||
### publish_rfq 字段映射(对接文档 4.1+4.2 节)
|
||||
|
||||
`lead.service.ts` → `publishToDirev(id, overrides?)` 组装 `publishArgs` 后调 `executeTool('publish_rfq', args)`。
|
||||
`overrides` 可选参数:`{ contactPhone?, contactName?, deliveryAddress? }`,由前端弹窗传入。
|
||||
|
||||
| publishArgs 字段 | lead 来源 / 默认值 | 文档要求 | 类型 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| title | `lead.title` | 询价标题 | string | 限 50 字,超长报错 |
|
||||
| items | MaterialExtractor 实时提取 | 物料清单行 | array | 可选,最多 20 行 |
|
||||
| deliveryAddress | `overrides?.deliveryAddress` → `lead.regionDetail` → `lead.region` | 收货地址 | string | 弹窗可编辑覆盖 |
|
||||
| deliveryDate | `deadline + 30 天` | 期望交货日期 | string YYYY-MM-DD | — |
|
||||
| validDays | `7` | 报价有效期 | integer | **W37 新增** |
|
||||
| contactName | `overrides?.contactName \|\| lead.contact \|\| '待补充'` | 联系人 | string | 弹窗可编辑 |
|
||||
| contactPhone | `overrides?.contactPhone \|\| lead.phone \|\| '待补充'` | 联系手机 | string | **必填,弹窗补全** |
|
||||
| inquiryParty | 硬编码 `郑州巨变网络科技有限公司` | 询价主体 | string | 企业配置 |
|
||||
| freightFlag | `1` | 运费承担 | integer | 1=含运费(曾传字符串"含运费"报错,已修复) |
|
||||
| procurementType | `1` | 采购类型 | integer | 1=公开采购(曾传字符串"定量采购"报错,已修复) |
|
||||
| quotationDemand | `1` | 报价要求 | integer | **W37 新增**,1=需要报价 |
|
||||
| invoiceType | `2` | 开票要求 | integer | 2=增值税专票(曾传字符串报错,已修复) |
|
||||
| paymentMethod | **不传** | 付款方式 | — | 后端用企业默认配置,传了"23"会报"企业字典找不到" |
|
||||
| userId / enterpriseId | `Integration.companyUserId` | 企业身份 | string | MCP connect 时获取 |
|
||||
|
||||
### 发布前确认弹窗(对接文档 §5.2,2026-07-16 实现)
|
||||
|
||||
`InquiryPage.tsx` 中实现,用户勾选线索后点"一键发布"弹出:
|
||||
|
||||
**弹窗布局**(三个区域):
|
||||
|
||||
| 区域 | 数据来源 | 可编辑 |
|
||||
|---|---|---|
|
||||
| 询价信息 | 线索标题/品类/数量/单位 | ❌ 只读 |
|
||||
| 收货信息 | 联系人、联系手机、收货地址 | ✅ 用户可编辑 |
|
||||
| 业务默认值 | 企业硬编码字段值清单 | ❌ 只读 |
|
||||
|
||||
**编辑字段**:
|
||||
|
||||
| 字段 | 类型 | 验证 | 为空时 |
|
||||
|---|---|---|---|
|
||||
| contactName | 文本输入框 | 无 | 传 `'待补充'` |
|
||||
| contactPhone | 电话输入框 | 标红提示"⚠ 必填" | 传 `'待补充'` |
|
||||
| deliveryAddress | 文本输入框 | 标红提示"⚠ 必填" | 传线索地区数据 |
|
||||
|
||||
**数据流**:
|
||||
|
||||
```
|
||||
前端弹窗 → DO_Publish() → POST /api/leads/:id/publish { contactName, contactPhone, deliveryAddress }
|
||||
→ lead.service.ts publishToDirev(id, overrides)
|
||||
→ 覆盖组装 publishArgs
|
||||
→ executeTool('publish_rfq', publishArgs)
|
||||
→ MCP server
|
||||
→ 产业平台询价广场
|
||||
```
|
||||
|
||||
### publish 端点变化(2026-07-16)
|
||||
|
||||
`lead.controller.ts`:
|
||||
|
||||
| 端点 | 方法 | Body | 改动 |
|
||||
|---|---|---|---|
|
||||
| `POST /api/leads/:id/publish` | POST | `{ contactPhone?, contactName?, deliveryAddress? }` | 新增可选 body 参数 |
|
||||
| `POST /api/leads/batch-publish` | POST | `{ ids: string[] }` | 未改(循环调 publish) |
|
||||
|
||||
### 当前已知卡点(对接文档 §8 + 2026-07-16 实测)
|
||||
|
||||
| 卡点 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `code:10021 "未获取到用户信息"` | ⏳ 产业平台修复中 | MCP server 用户上下文问题,创建 APIKey 时已关联 `companyUserId`,后端应直接使用 |
|
||||
| `paymentMethod` 传 `"23"` 报"企业字典找不到" | ✅ 已规避 | 已修改:不传 paymentMethod,后端用默认值 |
|
||||
| JSON 截断 / Content-Length 不匹配 | ✅ 已修复 | 加 `charset=utf-8` 后解决 |
|
||||
| 字段值类型不匹配(字符串 vs 整数) | ✅ 已修复 | `freightFlag`, `procurementType`, `invoiceType` 改整数,`paymentMethod` 不传 |
|
||||
| `tb_inquiry.title` varchar(50) 超长 | ✅ 已加前置校验 | 发布前 `publishToDirev()` 检查 title 长度,超 50 字提前报错 |
|
||||
|
||||
### 数据质量现状(2026-07-16 生产数据统计)
|
||||
|
||||
815 条线索中,以下字段**全军覆没**(采集器只抓了列表页元数据):
|
||||
|
||||
| 字段 | 填写率 | 说明 |
|
||||
|---|---|---|
|
||||
| deadline | 702/815 (86%) | 有 |
|
||||
| region | 696/815 (85%) | 有 |
|
||||
| regionDetail | 542/815 (66%) | 有 |
|
||||
| contact | 0/815 (0%) | **全缺** — 采集器未提取 |
|
||||
| phone | 0/815 (0%) | **全缺** |
|
||||
| quantity | 0/815 (0%) | **全缺** |
|
||||
| unit | 0/815 (0%) | **全缺** |
|
||||
| notes | 0/815 (0%) | **全缺** |
|
||||
|
||||
**原因**:三个采集脚本(中电建/fetch-v1.2.js、新矿/fetch-xkgroup.py、中核/fetch-cnncecp.py)都只解析列表页,不访问详情页正文提取联系人/电话/数量/单位。这些字段需要新增"详情页内容提取"步骤,或依赖发布前弹窗让用户手动补全。
|
||||
|
||||
**当前应对**:发布前弹窗让用户手动填写 contactPhone 和 deliveryAddress。如需自动化提取,需在 crawler.service.ts 入库后增加类似 `notes-extractor` 的 AI 提取步骤(待实现)。
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- 前端不加路由库,保持 SPA useState 模式
|
||||
|
||||
@ -14,6 +14,8 @@ interface MCPClientInstance {
|
||||
export class IntegrationService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(IntegrationService.name)
|
||||
private mcpClients: Map<string, MCPClientInstance> = new Map()
|
||||
/** MCP session id 缓存 (per integration, MCP Streamable HTTP 协议要求) */
|
||||
private mcpSessionIds: Map<string, string> = new Map()
|
||||
private readonly encryptKey: Buffer
|
||||
|
||||
constructor(private prisma: PrismaService) {
|
||||
@ -73,6 +75,58 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Streamable HTTP Session 管理 (per MCP spec 2025-03-26)
|
||||
*
|
||||
* 1. initialize 响应头里返回 mcp-session-id, 客户端必须提取并缓存
|
||||
* 2. 后续所有请求 (listTools/callTool/...) 必须带 mcp-session-id header
|
||||
* 3. 用 POST 方法, 不是 GET
|
||||
*
|
||||
* SDK 1.27.0 默认实现了这部分, 但在 transport 重建或 401 错误路径上可能漏掉。
|
||||
* 这里加一层显式 wrapper fetch 做"双重保险":
|
||||
* - 请求前: 自动注入缓存的 mcp-session-id
|
||||
* - 响应后: 从响应头提取 mcp-session-id 并缓存
|
||||
*/
|
||||
private buildSessionAwareFetch(integrationId: string): typeof fetch {
|
||||
return async (input: any, init?: RequestInit): Promise<Response> => {
|
||||
// 1. 注入已缓存的 session id (如果有)
|
||||
const headers = new Headers(init?.headers || {})
|
||||
|
||||
// 确保 Content-Type 带 charset=utf-8 (SDK 默认只设 application/json,
|
||||
// 不设 charset. Java Jackson 解析器缺 charset 时可能不以 UTF-8 解码,
|
||||
// 导致多字节中文字节计数与 Content-Length 不匹配, 报 Unexpected end-of-input)
|
||||
const ct = headers.get('content-type')
|
||||
if (ct && !ct.includes('charset') && ct.includes('application/json')) {
|
||||
headers.set('content-type', 'application/json; charset=utf-8')
|
||||
}
|
||||
|
||||
const cachedSessionId = this.mcpSessionIds.get(integrationId)
|
||||
if (cachedSessionId && !headers.has('mcp-session-id')) {
|
||||
headers.set('mcp-session-id', cachedSessionId)
|
||||
}
|
||||
|
||||
// 2. 发请求
|
||||
const response = await fetch(input, { ...init, headers })
|
||||
|
||||
// 3. 提取并缓存新的 session id (如果服务器返回了)
|
||||
const newSessionId = response.headers.get('mcp-session-id')
|
||||
if (newSessionId && newSessionId !== cachedSessionId) {
|
||||
this.mcpSessionIds.set(integrationId, newSessionId)
|
||||
this.logger.log(`[Session] ${integrationId} 获取新 session: ${newSessionId.slice(0, 8)}...`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空指定 integration 的 session (重连时用) */
|
||||
private clearSession(integrationId: string) {
|
||||
if (this.mcpSessionIds.has(integrationId)) {
|
||||
this.logger.log(`[Session] ${integrationId} 清空旧 session`)
|
||||
this.mcpSessionIds.delete(integrationId)
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
for (const [id, instance] of this.mcpClients) {
|
||||
try {
|
||||
@ -82,6 +136,7 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
this.mcpClients.clear()
|
||||
this.mcpSessionIds.clear()
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
@ -162,6 +217,8 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
|
||||
try {
|
||||
await this.disconnect(id)
|
||||
// 重连前清空旧 session id, 强制重新走 initialize
|
||||
this.clearSession(id)
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'platform-assistant', version: '1.0.0' },
|
||||
@ -174,11 +231,19 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
requestInit: {
|
||||
headers: this.buildAuthHeaders(integration),
|
||||
},
|
||||
// 自定义 fetch: 自动管理 mcp-session-id (MCP Streamable HTTP 协议要求)
|
||||
fetch: this.buildSessionAwareFetch(id),
|
||||
}
|
||||
)
|
||||
|
||||
await client.connect(transport)
|
||||
|
||||
// 兜底: 如果 SDK 在内部已经提取过 session, 同步到我们自己的缓存 (双保险)
|
||||
const sdkSessionId = (transport as any).sessionId
|
||||
if (sdkSessionId && !this.mcpSessionIds.has(id)) {
|
||||
this.mcpSessionIds.set(id, sdkSessionId)
|
||||
}
|
||||
|
||||
const toolsResult = await client.listTools()
|
||||
const tools = toolsResult.tools || []
|
||||
|
||||
@ -218,7 +283,9 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
},
|
||||
})
|
||||
|
||||
this.logger.log(`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}`)
|
||||
this.logger.log(
|
||||
`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}, session=${this.mcpSessionIds.get(id)?.slice(0, 8) || 'none'}`,
|
||||
)
|
||||
|
||||
return { success: true, tools }
|
||||
} catch (e: any) {
|
||||
@ -241,6 +308,8 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
}
|
||||
this.mcpClients.delete(id)
|
||||
}
|
||||
// 断开时清空 session, 下次 connect 强制重新走 initialize
|
||||
this.clearSession(id)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -260,6 +329,7 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
{ capabilities: {} }
|
||||
)
|
||||
|
||||
// 测试连接也走 session-aware fetch (用临时 integration id "test")
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(endpoint),
|
||||
{
|
||||
@ -269,6 +339,7 @@ export class IntegrationService implements OnModuleDestroy {
|
||||
'x-app-secret': appSecret,
|
||||
},
|
||||
},
|
||||
fetch: this.buildSessionAwareFetch('test'),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -55,8 +55,8 @@ export class LeadController {
|
||||
}
|
||||
|
||||
@Post(':id/publish')
|
||||
publish(@Param('id') id: string) {
|
||||
return this.leadService.publishToDirev(id)
|
||||
publish(@Param('id') id: string, @Body() body?: { contactPhone?: string; contactName?: string; deliveryAddress?: string }) {
|
||||
return this.leadService.publishToDirev(id, body)
|
||||
}
|
||||
|
||||
@Post('batch-publish')
|
||||
|
||||
@ -248,7 +248,10 @@ export class LeadService {
|
||||
})
|
||||
}
|
||||
|
||||
async publishToDirev(id: string) {
|
||||
async publishToDirev(
|
||||
id: string,
|
||||
overrides?: { contactPhone?: string; contactName?: string; deliveryAddress?: string },
|
||||
) {
|
||||
const lead = await this.findOne(id)
|
||||
const now = new Date()
|
||||
|
||||
@ -290,17 +293,22 @@ export class LeadService {
|
||||
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 (overrides?.deliveryAddress) publishArgs.deliveryAddress = overrides.deliveryAddress
|
||||
else if (lead.regionDetail) publishArgs.deliveryAddress = lead.regionDetail
|
||||
else if (lead.region) publishArgs.deliveryAddress = lead.region
|
||||
|
||||
if (lead.contact) publishArgs.contactName = lead.contact
|
||||
if (lead.phone) publishArgs.contactPhone = lead.phone
|
||||
// 联系人信息(弹窗用户可编辑覆盖,否则取线索数据,最后用占位)
|
||||
publishArgs.contactName = overrides?.contactName || lead.contact || '待补充'
|
||||
publishArgs.contactPhone = overrides?.contactPhone || lead.phone || '待补充'
|
||||
|
||||
// 企业级业务字段(按 MCP publish_rfq 对接文档 4.2 节字段类型和值)
|
||||
publishArgs.inquiryParty = '郑州巨变网络科技有限公司'
|
||||
publishArgs.freightFlag = '含运费'
|
||||
publishArgs.procurementType = '定量采购'
|
||||
publishArgs.invoiceType = '增值税专票'
|
||||
publishArgs.paymentMethod = '货到付款'
|
||||
publishArgs.freightFlag = 1 // 1=含运费(int,不要传字符串)
|
||||
publishArgs.procurementType = 1 // 1=公开采购
|
||||
publishArgs.invoiceType = 2 // 2=增值税专票
|
||||
// paymentMethod 不传,后端会用企业默认配置(传了"23"反而会报"企业字典找不到")
|
||||
publishArgs.quotationDemand = 1 // 1=需要报价(新增,文档要求)
|
||||
publishArgs.validDays = 7 // 报价有效期天数(新增,文档要求)
|
||||
|
||||
// Auto-fill enterprise identity from local Integration config
|
||||
if (b2bIntegration.companyUserId) {
|
||||
|
||||
@ -6,3 +6,24 @@ body {
|
||||
background: #f7f9fb;
|
||||
color: #191c1e;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/components/AuthProvider'
|
||||
import { ToastProvider } from '@/components/ui/Toast'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import LoginPage from '@/components/LoginPage'
|
||||
import OverviewPage from '@/components/OverviewPage'
|
||||
@ -38,20 +40,24 @@ export default function Home() {
|
||||
if (!user) return <LoginPage />
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar current={page} onNavigate={setPage} user={user} onLogout={logout} />
|
||||
<main className="flex-1 ml-64 p-8">
|
||||
{page === 'overview' && <OverviewPage onNavigate={(p) => setPage(p as Page)} />}
|
||||
{page === 'inquiry' && <InquiryPage onLeadDetail={handleLeadDetail} />}
|
||||
{page === 'image' && <ImagePage />}
|
||||
{page === 'product' && <ProductPage />}
|
||||
{page === 'comparison' && <ComparisonPage />}
|
||||
{page === 'lead-detail' && <LeadDetailPage leadId={leadDetailId!} onNavigate={(p) => setPage(p as Page)} />}
|
||||
{page === 'report-center' && <ReportCenterPage />}
|
||||
{page === 'rule-center' && <RuleCenterPage />}
|
||||
{page === 'system-monitor' && <SystemMonitorPage />}
|
||||
{page.startsWith('admin-') && <AdminPage section={page.replace('admin-', '') as any} />}
|
||||
</main>
|
||||
</div>
|
||||
<ToastProvider>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar current={page} onNavigate={setPage} user={user} onLogout={logout} />
|
||||
<main className="flex-1 ml-64 p-8">
|
||||
<ErrorBoundary>
|
||||
{page === 'overview' && <OverviewPage onNavigate={(p) => setPage(p as Page)} />}
|
||||
{page === 'inquiry' && <InquiryPage onLeadDetail={handleLeadDetail} />}
|
||||
{page === 'image' && <ImagePage />}
|
||||
{page === 'product' && <ProductPage />}
|
||||
{page === 'comparison' && <ComparisonPage />}
|
||||
{page === 'lead-detail' && <LeadDetailPage leadId={leadDetailId!} onNavigate={(p) => setPage(p as Page)} />}
|
||||
{page === 'report-center' && <ReportCenterPage />}
|
||||
{page === 'rule-center' && <RuleCenterPage />}
|
||||
{page === 'system-monitor' && <SystemMonitorPage />}
|
||||
{page.startsWith('admin-') && <AdminPage section={page.replace('admin-', '') as any} />}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { API_BASE } from '@/lib/apiBase'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@ -56,18 +57,15 @@ const authHeaders = (): Record<string, string> => {
|
||||
}
|
||||
|
||||
function toastManager() {
|
||||
const [toast, setToast] = useState('')
|
||||
const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
||||
return { toast, show }
|
||||
// Legacy wrapper - now delegates to useToast via context
|
||||
return { toast: '', show: (_msg: string) => {} }
|
||||
}
|
||||
|
||||
export default function AdminPage({ section }: { section: Tab }) {
|
||||
const { toast, show: showToast } = toastManager()
|
||||
const { showToast } = useToast()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{toast && <div className="fixed top-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg text-sm z-50 shadow-lg">{toast}</div>}
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">运营后台</h1>
|
||||
@ -1369,6 +1367,9 @@ function ConfigTab({ showToast }: { showToast: (m: string) => void }) {
|
||||
<input type="text" value={integrationForm.endpoint} onChange={(e) => 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" />
|
||||
<p className="text-xs text-on-surface-variant mt-1">
|
||||
必须是 Streamable HTTP 协议的 MCP 端点 (POST 方法)。例: <code className="bg-gray-50 px-1 rounded">http://host:port/mcp</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1">appId *</label>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { API_BASE } from '@/lib/apiBase'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { SkeletonTable } from '@/components/ui/Skeleton'
|
||||
|
||||
interface SupplierInfo {
|
||||
id: string
|
||||
@ -45,7 +47,7 @@ export default function ComparisonPage() {
|
||||
const [report, setReport] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [suppliers, setSuppliers] = useState<SupplierUpload[]>([])
|
||||
const [toast, setToast] = useState('')
|
||||
const { showToast } = useToast()
|
||||
const [sortBy, setSortBy] = useState<'price' | 'name' | 'savings'>('price')
|
||||
const [splitSupplierCount, setSplitSupplierCount] = useState<number | null>(null)
|
||||
|
||||
@ -55,11 +57,6 @@ export default function ComparisonPage() {
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(''), 2500)
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [compRes, suggRes] = await Promise.all([
|
||||
@ -134,7 +131,7 @@ export default function ComparisonPage() {
|
||||
|
||||
const allSupplierNames = [...new Set(data.flatMap(i => i.suppliers.map(s => s.name)))].filter(Boolean)
|
||||
|
||||
if (loading) return <div className="text-center py-20 text-on-surface-variant">加载中...</div>
|
||||
if (loading) return <SkeletonTable rows={5} cols={6} />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -629,12 +626,6 @@ export default function ComparisonPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-lg text-xs shadow-lg z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
apps/web/src/components/ErrorBoundary.tsx
Normal file
79
apps/web/src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[ErrorBoundary]', error, errorInfo)
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center">
|
||||
<div className="text-center max-w-md space-y-4">
|
||||
<div className="w-16 h-16 bg-rose-50 rounded-full flex items-center justify-center mx-auto">
|
||||
<svg className="w-8 h-8 text-rose-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-on-surface mb-1">页面出错了</h2>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
渲染过程中发生了未知错误,请尝试刷新页面或点击下方重试按钮。
|
||||
</p>
|
||||
</div>
|
||||
{this.state.error && (
|
||||
<pre className="text-xs text-left bg-slate-50 border border-border-subtle rounded-lg p-3 overflow-x-auto text-rose-600">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="px-5 py-2 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-5 py-2 bg-white border border-border-subtle rounded-lg text-sm font-semibold hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@ import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import JSZip from 'jszip'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { API_BASE } from '@/lib/apiBase'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import Lightbox from '@/components/ui/Lightbox'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
interface ImageRecord {
|
||||
id: string
|
||||
@ -45,8 +48,8 @@ export default function ImagePage() {
|
||||
const [selectedStyle, setSelectedStyle] = useState('white')
|
||||
const [selectedSize, setSelectedSize] = useState('1:1')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [toast, setToast] = useState('')
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const { showToast } = useToast()
|
||||
|
||||
// Upload state
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
@ -59,11 +62,6 @@ export default function ImagePage() {
|
||||
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())
|
||||
@ -292,7 +290,19 @@ export default function ImagePage() {
|
||||
}
|
||||
}, [matchMap, productList, images])
|
||||
|
||||
if (loading) return <div className="text-center py-20 text-on-surface-variant">加载中...</div>
|
||||
if (loading) return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<div className="h-7 w-40 bg-slate-200 rounded animate-pulse" />
|
||||
<div className="h-4 w-64 bg-slate-100 rounded animate-pulse mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{Array.from({ length: 12 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -649,23 +659,7 @@ export default function ImagePage() {
|
||||
</div>
|
||||
|
||||
{/* Preview Lightbox */}
|
||||
{previewUrl && (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-8" onClick={() => setPreviewUrl(null)}>
|
||||
<div className="relative max-w-[90vw] max-h-[90vh]" onClick={e => e.stopPropagation()}>
|
||||
<img src={previewUrl} alt="预览" className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl" />
|
||||
<button onClick={() => setPreviewUrl(null)} className="absolute -top-3 -right-3 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-slate-100">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-lg text-xs shadow-lg z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
<Lightbox src={previewUrl} onClose={() => setPreviewUrl(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { API_BASE } from '@/lib/apiBase'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import { SkeletonTable } from '@/components/ui/Skeleton'
|
||||
|
||||
interface Lead {
|
||||
id: string
|
||||
@ -95,6 +98,7 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
||||
const [showSModal, setShowSModal] = useState(false)
|
||||
const [showPubModal, setShowPubModal] = useState(false)
|
||||
const [pubLoading, setPubLoading] = useState(false)
|
||||
const [pubForm, setPubForm] = useState({ contactName: '', contactPhone: '', deliveryAddress: '' })
|
||||
const [publishFailModal, setPublishFailModal] = useState<{
|
||||
failed: Array<{ title: string; error: string }>
|
||||
success: number
|
||||
@ -104,11 +108,9 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
||||
const [batchStatus, setBatchStatus] = useState('跟进中')
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
const [batchNote, setBatchNote] = useState('')
|
||||
const [toast, setToast] = useState('')
|
||||
const { showToast } = useToast()
|
||||
const pageSize = 10
|
||||
|
||||
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
||||
|
||||
const [fTime, setFTime] = useState('全部日期')
|
||||
const [fStar, setFStar] = useState('全部星级')
|
||||
const [fStatus, setFStatus] = useState('全部状态')
|
||||
@ -224,6 +226,13 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
||||
const oneClickPublish = () => {
|
||||
const selected = filtered.filter(l => selectedIds.has(l.id))
|
||||
if (selected.length === 0) return
|
||||
// 从第一个选中线索预填联系人/地址
|
||||
const first = selected[0]
|
||||
setPubForm({
|
||||
contactName: first.contact || '',
|
||||
contactPhone: first.phone || '',
|
||||
deliveryAddress: first.regionDetail || first.region || '',
|
||||
})
|
||||
setShowPubModal(true)
|
||||
}
|
||||
|
||||
@ -243,7 +252,15 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
||||
const failed: Array<{ title: string; error: string }> = []
|
||||
for (const lead of selected) {
|
||||
try {
|
||||
const res = await fetch(`${API}/${lead.id}/publish`, { method: 'POST' })
|
||||
const res = await fetch(`${API}/${lead.id}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contactName: pubForm.contactName || undefined,
|
||||
contactPhone: pubForm.contactPhone || undefined,
|
||||
deliveryAddress: pubForm.deliveryAddress || undefined,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
lead.publish = '已发布'
|
||||
success++
|
||||
@ -530,7 +547,7 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-subtle">
|
||||
{loading ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-sm text-on-surface-variant">加载中...</td></tr>
|
||||
<tr><td colSpan={10} className="p-8"><SkeletonTable rows={5} cols={10} /></td></tr>
|
||||
) : pageLeads.length === 0 ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-sm text-on-surface-variant">暂无数据</td></tr>
|
||||
) : pageLeads.map(lead => {
|
||||
@ -737,117 +754,144 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
||||
)}
|
||||
|
||||
{/* S级甲方动态 Modal */}
|
||||
{showSModal && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowSModal(false)}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] max-h-[calc(100vh-120px)] overflow-y-auto transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-secondary" fill="currentColor" viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" /></svg>
|
||||
S级甲方动态
|
||||
</h3>
|
||||
<button onClick={() => setShowSModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="text-xs text-slate-400 mb-4 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
近7日动态 · 按时间倒序排列
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sSorted.map(l => (
|
||||
<div key={l.id} className="p-3 rounded-lg bg-slate-50 hover:bg-blue-50 transition-colors cursor-pointer border border-slate-100">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-secondary mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" /></svg>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-900">{l.title}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">{l.org} · {l.cat}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-blue-100 text-blue-700">{l.level}</span>
|
||||
<div className="text-[10px] text-slate-400 mt-1">截止 {l.deadline}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-[10px] ${l.status === '待跟进' ? 'text-rose-500' : l.status === '已转询价' ? 'text-emerald-500' : 'text-blue-500'}`}>{l.status}</span>
|
||||
<span className="text-[10px] text-amber-500">{'★'.repeat(l.star)}{'☆'.repeat(5 - l.star)}</span>
|
||||
</div>
|
||||
<Modal
|
||||
open={showSModal}
|
||||
onClose={() => setShowSModal(false)}
|
||||
title="S级甲方动态"
|
||||
subtitle="近7日动态 · 按时间倒序排列"
|
||||
width="w-[520px]"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{sSorted.map(l => (
|
||||
<div key={l.id} className="p-3 rounded-lg bg-slate-50 hover:bg-blue-50 transition-colors cursor-pointer border border-slate-100">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-secondary mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" /></svg>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-900">{l.title}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">{l.org} · {l.cat}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-blue-100 text-blue-700">{l.level}</span>
|
||||
<div className="text-[10px] text-slate-400 mt-1">截止 {l.deadline}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-[10px] ${l.status === '待跟进' ? 'text-rose-500' : l.status === '已转询价' ? 'text-emerald-500' : 'text-blue-500'}`}>{l.status}</span>
|
||||
<span className="text-[10px] text-amber-500">{'★'.repeat(l.star)}{'☆'.repeat(5 - l.star)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 一键发布 Modal */}
|
||||
{showPubModal && (() => {
|
||||
const lead = filtered.find(l => selectedIds.has(l.id))
|
||||
if (!lead) return null
|
||||
const selectedCount = filtered.filter(l => selectedIds.has(l.id)).length
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!pubLoading) setShowPubModal(false) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">一键发布 direv</h3>
|
||||
<button disabled={pubLoading} onClick={() => setShowPubModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
<Modal
|
||||
open={showPubModal}
|
||||
onClose={() => setShowPubModal(false)}
|
||||
title="确认发布询价单"
|
||||
subtitle={`即将发布至 www.direv.com.cn(共 ${selectedCount} 条线索)`}
|
||||
width="w-[560px]"
|
||||
loading={pubLoading}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={pubLoading} onClick={doPublish} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{pubLoading ? '发布中...' : '确认发布'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="text-xs">即将发布以下线索至 <b>direv.com.cn</b>:</div>
|
||||
<div className="p-3 bg-slate-50 rounded text-xs space-y-1">
|
||||
<b>{lead.title}</b><br />
|
||||
品类:{lead.cat} | 数量:待确认 | 截止:{lead.deadline}<br />
|
||||
联系:郑钊 15532027557
|
||||
<button disabled={pubLoading} onClick={() => setShowPubModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* 询价信息 */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">— 询价信息(从线索自动填充)</h4>
|
||||
<div className="p-3 bg-slate-50 rounded text-xs space-y-1.5">
|
||||
<div><span className="text-on-surface-variant">询价标题:</span><b>{lead.title}</b></div>
|
||||
<div><span className="text-on-surface-variant">采购产品:</span>{lead.title.slice(0, 40)}</div>
|
||||
<div className="flex gap-4">
|
||||
<span><span className="text-on-surface-variant">数量:</span>{lead.quantity || '待确认'}</span>
|
||||
<span><span className="text-on-surface-variant">单位:</span>{lead.unit || '批'}</span>
|
||||
</div>
|
||||
<div><span className="text-on-surface-variant">品类:</span>{lead.cat || '未分类'} <span className="text-on-surface-variant ml-4">截止:</span>{lead.deadline || '无'}</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400">将自动匹配联系人、收货地址等信息后发布。</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button disabled={pubLoading} onClick={doPublish} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{pubLoading ? '发布中...' : '确认发布'}
|
||||
</button>
|
||||
<button disabled={pubLoading} onClick={() => setShowPubModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</div>
|
||||
|
||||
{/* 收货信息(用户可编辑) */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">— 收货信息 <span className="text-error-red">*必填</span></h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">联系人</label>
|
||||
<input type="text" value={pubForm.contactName}
|
||||
onChange={(e) => setPubForm({ ...pubForm, contactName: e.target.value })}
|
||||
placeholder="线索联系人"
|
||||
className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs outline-none focus:border-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">联系手机 *</label>
|
||||
<input type="tel" value={pubForm.contactPhone}
|
||||
onChange={(e) => setPubForm({ ...pubForm, contactPhone: e.target.value })}
|
||||
placeholder="必填,请输入手机号"
|
||||
className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs outline-none focus:border-secondary"
|
||||
required />
|
||||
{!pubForm.contactPhone && <p className="text-error-red text-[10px] mt-0.5">⚠ 必填</p>}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium mb-1">收货地址 *</label>
|
||||
<input type="text" value={pubForm.deliveryAddress}
|
||||
onChange={(e) => setPubForm({ ...pubForm, deliveryAddress: e.target.value })}
|
||||
placeholder="必填,请输入收货地址"
|
||||
className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs outline-none focus:border-secondary"
|
||||
required />
|
||||
{!pubForm.deliveryAddress && <p className="text-error-red text-[10px] mt-0.5">⚠ 必填</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">交货日期</label>
|
||||
<input type="date" value={lead.deadline ? lead.deadline.slice(0, 10) : ''} disabled
|
||||
className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs bg-gray-50 outline-none text-on-surface-variant" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">报价有效期</label>
|
||||
<input type="text" value="7 天(默认)" disabled
|
||||
className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs bg-gray-50 outline-none text-on-surface-variant" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 业务默认值 */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">— 业务默认值(企业配置,不可修改)</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs p-3 bg-gray-50 rounded">
|
||||
<div><span className="text-on-surface-variant">询价主体:</span>郑州巨变网络科技有限公司</div>
|
||||
<div><span className="text-on-surface-variant">采购类型:</span>公开采购 (1)</div>
|
||||
<div><span className="text-on-surface-variant">开票要求:</span>增值税专票 (2)</div>
|
||||
<div><span className="text-on-surface-variant">运费承担:</span>含运费 (1)</div>
|
||||
<div><span className="text-on-surface-variant">报价要求:</span>需要报价 (1)</div>
|
||||
<div><span className="text-on-surface-variant">付款方式:</span>企业默认(不传)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 发布失败 Modal */}
|
||||
{publishFailModal && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center pt-20" onClick={() => setPublishFailModal(null)}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[640px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
发布失败
|
||||
</h3>
|
||||
<p className="text-xs text-on-surface-variant mt-1">
|
||||
以下 {publishFailModal.failed.length} 条线索未成功发布到 B2B 平台
|
||||
{publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''}
|
||||
,请检查 MCP 对接配置后重试。
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setPublishFailModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3 overflow-y-auto flex-1">
|
||||
{publishFailModal.failed.map((f, i) => (
|
||||
<div key={i} className="border border-red-200 bg-red-50/50 rounded-lg p-3">
|
||||
<div className="text-xs font-semibold text-red-900 mb-1 truncate">{f.title}</div>
|
||||
<div className="text-[11px] text-red-700 leading-relaxed">{f.error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 border-t border-border-subtle flex gap-2 justify-end">
|
||||
<Modal
|
||||
open={!!publishFailModal}
|
||||
onClose={() => setPublishFailModal(null)}
|
||||
title="发布失败"
|
||||
subtitle={`以下 ${publishFailModal.failed.length} 条线索未成功发布到 B2B 平台${publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''},请检查 MCP 对接配置后重试。`}
|
||||
width="w-[640px]"
|
||||
footer={
|
||||
<>
|
||||
<a
|
||||
href="#"
|
||||
onClick={e => { e.preventDefault(); setPublishFailModal(null); window.open('http://localhost:3001/#config', '_blank') }}
|
||||
@ -858,144 +902,139 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
||||
<button onClick={() => setPublishFailModal(null)} className="px-4 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90">
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{publishFailModal.failed.map((f, i) => (
|
||||
<div key={i} className="border border-red-200 bg-red-50/50 rounded-lg p-3">
|
||||
<div className="text-xs font-semibold text-red-900 mb-1 truncate">{f.title}</div>
|
||||
<div className="text-[11px] text-red-700 leading-relaxed">{f.error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 分配负责人 Modal */}
|
||||
{batchModal === 'assign' && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">分配负责人</h3>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索分配给:</div>
|
||||
<select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
{PERSON_OPTIONS.filter(p => p !== '全部人员' && p !== '未分配').map(p => <option key={p}>{p}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button disabled={batchLoading} onClick={confirmBatchAssign} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认分配'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={batchModal === 'assign'}
|
||||
onClose={() => setBatchModal(null)}
|
||||
title="分配负责人"
|
||||
width="w-[480px]"
|
||||
loading={batchLoading}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={batchLoading} onClick={confirmBatchAssign} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认分配'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索分配给:</div>
|
||||
<select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
{PERSON_OPTIONS.filter(p => p !== '全部人员' && p !== '未分配').map(p => <option key={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 批量转询价 Modal */}
|
||||
{batchModal === 'rfq' && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">批量转询价</h3>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建询价单:</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
||||
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选,预填询价备注" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button disabled={batchLoading} onClick={batchToRFQ} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认转询价'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={batchModal === 'rfq'}
|
||||
onClose={() => setBatchModal(null)}
|
||||
title="批量转询价"
|
||||
width="w-[520px]"
|
||||
loading={batchLoading}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={batchLoading} onClick={batchToRFQ} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认转询价'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建询价单:</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
||||
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选,预填询价备注" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 批量转协同 Modal */}
|
||||
{batchModal === 'collab' && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">批量转协同</h3>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建协同任务:</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
||||
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button disabled={batchLoading} onClick={batchToCollab} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认转协同'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={batchModal === 'collab'}
|
||||
onClose={() => setBatchModal(null)}
|
||||
title="批量转协同"
|
||||
width="w-[520px]"
|
||||
loading={batchLoading}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={batchLoading} onClick={batchToCollab} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认转协同'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建协同任务:</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
||||
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 变更状态 Modal */}
|
||||
{batchModal === 'status' && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">变更状态</h3>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索状态变更为:</div>
|
||||
<select value={batchStatus} onChange={e => setBatchStatus(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
{STATUS_OPTIONS.filter(s => s !== '全部状态' && s !== '无效线索').map(s => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button disabled={batchLoading} onClick={confirmBatchStatus} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认变更'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={batchModal === 'status'}
|
||||
onClose={() => setBatchModal(null)}
|
||||
title="变更状态"
|
||||
width="w-[480px]"
|
||||
loading={batchLoading}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={batchLoading} onClick={confirmBatchStatus} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{batchLoading ? '处理中...' : '确认变更'}
|
||||
</button>
|
||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索状态变更为:</div>
|
||||
<select value={batchStatus} onChange={e => setBatchStatus(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
{STATUS_OPTIONS.filter(s => s !== '全部状态' && s !== '无效线索').map(s => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-5 py-2.5 rounded-lg text-sm shadow-lg z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { API_BASE } from '@/lib/apiBase'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
|
||||
interface FollowUp {
|
||||
date: string
|
||||
@ -103,14 +105,13 @@ export default function LeadDetailPage({ leadId, onNavigate }: { leadId: string;
|
||||
const [newFollowContent, setNewFollowContent] = useState('')
|
||||
const [newFollowType, setNewFollowType] = useState<'电话沟通' | '邮件往来' | '现场拜访' | '线上会议'>('电话沟通')
|
||||
const [bidExpanded, setBidExpanded] = useState(false)
|
||||
const [toast, setToast] = useState('')
|
||||
const { showToast } = useToast()
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [showRFQModal, setShowRFQModal] = useState(false)
|
||||
const [showCollabModal, setShowCollabModal] = useState(false)
|
||||
const [modalAssignee, setModalAssignee] = useState('郑钊')
|
||||
const [modalNote, setModalNote] = useState('')
|
||||
|
||||
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
||||
|
||||
useEffect(() => {
|
||||
if (!leadId) return
|
||||
@ -552,78 +553,68 @@ export default function LeadDetailPage({ leadId, onNavigate }: { leadId: string;
|
||||
</div>
|
||||
|
||||
{/* 转询价 Modal */}
|
||||
{showRFQModal && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!actionLoading) setShowRFQModal(false) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">转为询价</h3>
|
||||
<button disabled={!!actionLoading} onClick={() => setShowRFQModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button disabled={!!actionLoading} onClick={async () => { await doAction('to-rfq', 'POST', { assignee: modalAssignee, note: modalNote }); setShowRFQModal(false) }} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{actionLoading === 'to-rfq' ? '处理中...' : '确认转询价'}
|
||||
</button>
|
||||
<button disabled={!!actionLoading} onClick={() => setShowRFQModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={showRFQModal}
|
||||
onClose={() => setShowRFQModal(false)}
|
||||
title="转为询价"
|
||||
width="w-[480px]"
|
||||
loading={!!actionLoading}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={!!actionLoading} onClick={async () => { await doAction('to-rfq', 'POST', { assignee: modalAssignee, note: modalNote }); setShowRFQModal(false) }} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
|
||||
{actionLoading === 'to-rfq' ? '处理中...' : '确认转询价'}
|
||||
</button>
|
||||
<button disabled={!!actionLoading} onClick={() => setShowRFQModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 转协同 Modal */}
|
||||
{showCollabModal && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!actionLoading) setShowCollabModal(false) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">转为协同</h3>
|
||||
<button disabled={!!actionLoading} onClick={() => setShowCollabModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button disabled={!!actionLoading} onClick={async () => { await doAction('to-collab', 'POST', { assignee: modalAssignee, note: modalNote }); setShowCollabModal(false) }} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50">
|
||||
{actionLoading === 'to-collab' ? '处理中...' : '确认转协同'}
|
||||
</button>
|
||||
<button disabled={!!actionLoading} onClick={() => setShowCollabModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={showCollabModal}
|
||||
onClose={() => setShowCollabModal(false)}
|
||||
title="转为协同"
|
||||
width="w-[480px]"
|
||||
loading={!!actionLoading}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={!!actionLoading} onClick={async () => { await doAction('to-collab', 'POST', { assignee: modalAssignee, note: modalNote }); setShowCollabModal(false) }} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50">
|
||||
{actionLoading === 'to-collab' ? '处理中...' : '确认转协同'}
|
||||
</button>
|
||||
<button disabled={!!actionLoading} onClick={() => setShowCollabModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||
<select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
|
||||
<option value="郑钊">郑钊</option>
|
||||
<option value="赵主任">赵主任</option>
|
||||
<option value="运营管理员">运营管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||
<textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-5 py-2.5 rounded-lg text-sm shadow-lg z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -346,13 +346,14 @@ export default function OverviewPage({ onNavigate }: OverviewPageProps) {
|
||||
<tbody className="divide-y divide-border-subtle">
|
||||
{health.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-5 py-8 text-center text-sm text-on-surface-variant">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
正在连接后端服务...
|
||||
<td colSpan={4} className="px-5 py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-secondary animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<div className="w-2 h-2 rounded-full bg-secondary animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-2 h-2 rounded-full bg-secondary animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
<span className="text-xs text-on-surface-variant">正在连接后端服务...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { API_BASE } from '@/lib/apiBase'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import Lightbox from '@/components/ui/Lightbox'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import { SkeletonTable } from '@/components/ui/Skeleton'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
@ -55,9 +59,9 @@ export default function ProductPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [showTemplate, setShowTemplate] = useState(false)
|
||||
const [toast, setToast] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState({ total: 0, pending: 0, published: 0, failed: 0 })
|
||||
const { showToast } = useToast()
|
||||
|
||||
// File import state
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
@ -126,11 +130,6 @@ export default function ProductPage() {
|
||||
const totalPages = Math.ceil(filtered.length / pageSize) || 1
|
||||
const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(''), 2500)
|
||||
}
|
||||
|
||||
const filterByStatus = (status: string) => {
|
||||
setCurrentFilter(status)
|
||||
setPage(1)
|
||||
@ -400,7 +399,7 @@ export default function ProductPage() {
|
||||
{ key: 'failed', label: '同步失败', value: stats.failed, color: 'text-rose-600', suffix: '条同步失败', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z' },
|
||||
]
|
||||
|
||||
if (loading) return <div className="text-center py-20 text-on-surface-variant">加载中...</div>
|
||||
if (loading) return <SkeletonTable rows={8} cols={8} />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -595,125 +594,99 @@ export default function ProductPage() {
|
||||
</div>
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImport && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!importing) setShowImport(false) }}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[680px] max-h-[calc(100vh-100px)] overflow-y-auto transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">导入商品</h2>
|
||||
<p className="text-[10px] text-slate-500 mt-1">支持 .xlsx, .xls, .csv 格式</p>
|
||||
<Modal
|
||||
open={showImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
title="导入商品"
|
||||
subtitle="支持 .xlsx, .xls, .csv 格式"
|
||||
width="w-[680px]"
|
||||
loading={importing}
|
||||
footer={
|
||||
<>
|
||||
<button disabled={importing} onClick={() => setShowImport(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs">取消</button>
|
||||
<button disabled={!importPreview || importing} onClick={confirmImport}
|
||||
className="px-4 py-2 bg-secondary text-white rounded-lg text-xs font-medium disabled:opacity-50 hover:opacity-90">
|
||||
{importing ? '导入中...' : '确认导入'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Upload Zone */}
|
||||
{!importFile && (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => { 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()}>
|
||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={e => { if (e.target.files?.[0]) processFile(e.target.files[0]) }} />
|
||||
<svg className="w-10 h-10 mx-auto text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||
<p className="mt-2 text-xs text-slate-700">拖拽文件到此处,或 <a className="text-secondary hover:underline">点击选择文件</a></p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">建议使用提供的商品模板进行导入</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Info */}
|
||||
{importFile && (
|
||||
<div className="p-3 bg-slate-50 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" /></svg>
|
||||
<div>
|
||||
<div className="text-xs font-medium">{importFile.name}</div>
|
||||
<div className="text-[10px] text-slate-500">{importFile.size < 1024 ? `${importFile.size} B` : importFile.size < 1048576 ? `${(importFile.size / 1024).toFixed(1)} KB` : `${(importFile.size / 1048576).toFixed(1)} MB`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button disabled={importing} onClick={() => setShowImport(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
<button onClick={() => { setImportFile(null); setImportPreview(null) }} className="p-1 hover:bg-slate-200 rounded">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Upload Zone */}
|
||||
{!importFile && (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => { 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()}>
|
||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={e => { if (e.target.files?.[0]) processFile(e.target.files[0]) }} />
|
||||
<svg className="w-10 h-10 mx-auto text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||
<p className="mt-2 text-xs text-slate-700">拖拽文件到此处,或 <a className="text-secondary hover:underline">点击选择文件</a></p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">建议使用提供的商品模板进行导入</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* File Info */}
|
||||
{importFile && (
|
||||
<div className="p-3 bg-slate-50 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" /></svg>
|
||||
<div>
|
||||
<div className="text-xs font-medium">{importFile.name}</div>
|
||||
<div className="text-[10px] text-slate-500">{importFile.size < 1024 ? `${importFile.size} B` : importFile.size < 1048576 ? `${(importFile.size / 1024).toFixed(1)} KB` : `${(importFile.size / 1048576).toFixed(1)} MB`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => { setImportFile(null); setImportPreview(null) }} className="p-1 hover:bg-slate-200 rounded">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{importPreview && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-700 mb-2">数据预览(前5条)</div>
|
||||
<div className="overflow-x-auto max-h-48 overflow-y-auto border border-border-subtle rounded-lg">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="bg-slate-100 sticky top-0">
|
||||
<tr>
|
||||
{['商品标题', '品牌', '规格', '单位', '价格'].map(h => (
|
||||
<th key={h} className="p-2 text-left font-medium text-slate-600">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importPreview.slice(0, 5).map((p, i) => (
|
||||
<tr key={i} className="border-b border-slate-100">
|
||||
<td className="p-2 text-slate-700">{p.title}</td>
|
||||
<td className="p-2 text-slate-700">{p.brand}</td>
|
||||
<td className="p-2 text-slate-700">{p.spec}</td>
|
||||
<td className="p-2 text-slate-700">{p.unit}</td>
|
||||
<td className="p-2 text-slate-700">¥{p.price.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-slate-500">共 {importPreview.length} 条数据将导入</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border-subtle">
|
||||
<button disabled={importing} onClick={() => setShowImport(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs">取消</button>
|
||||
<button disabled={!importPreview || importing} onClick={confirmImport}
|
||||
className="px-4 py-2 bg-secondary text-white rounded-lg text-xs font-medium disabled:opacity-50 hover:opacity-90">
|
||||
{importing ? '导入中...' : '确认导入'}
|
||||
</button>
|
||||
{/* Preview */}
|
||||
{importPreview && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-700 mb-2">数据预览(前5条)</div>
|
||||
<div className="overflow-x-auto max-h-48 overflow-y-auto border border-border-subtle rounded-lg">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="bg-slate-100 sticky top-0">
|
||||
<tr>
|
||||
{['商品标题', '品牌', '规格', '单位', '价格'].map(h => (
|
||||
<th key={h} className="p-2 text-left font-medium text-slate-600">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importPreview.slice(0, 5).map((p, i) => (
|
||||
<tr key={i} className="border-b border-slate-100">
|
||||
<td className="p-2 text-slate-700">{p.title}</td>
|
||||
<td className="p-2 text-slate-700">{p.brand}</td>
|
||||
<td className="p-2 text-slate-700">{p.spec}</td>
|
||||
<td className="p-2 text-slate-700">{p.unit}</td>
|
||||
<td className="p-2 text-slate-700">¥{p.price.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-slate-500">共 {importPreview.length} 条数据将导入</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Template Modal */}
|
||||
{showTemplate && (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowTemplate(false)}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||
<h3 className="text-base font-semibold">商品导入模板说明</h3>
|
||||
<button onClick={() => setShowTemplate(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-xs text-slate-600 mb-3">请按以下列顺序准备Excel文件:</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px]">
|
||||
{['商品编号', 'SKU编号', '商品标题', '品牌', '一级分类', '二级分类', '规格型号', '单位', '原价', '现价', '税率'].map((c, i) => (
|
||||
<div key={c} className="p-2 bg-slate-50 rounded"><span className="font-medium">{i + 1}.</span> {c}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal open={showTemplate} onClose={() => setShowTemplate(false)} title="商品导入模板说明" width="w-[480px]">
|
||||
<p className="text-xs text-slate-600 mb-3">请按以下列顺序准备Excel文件:</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px]">
|
||||
{['商品编号', 'SKU编号', '商品标题', '品牌', '一级分类', '二级分类', '规格型号', '单位', '原价', '现价', '税率'].map((c, i) => (
|
||||
<div key={c} className="p-2 bg-slate-50 rounded"><span className="font-medium">{i + 1}.</span> {c}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Preview Lightbox */}
|
||||
{previewUrl && (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-8" onClick={() => setPreviewUrl(null)}>
|
||||
<div className="relative max-w-[90vw] max-h-[90vh]" onClick={e => e.stopPropagation()}>
|
||||
<img src={previewUrl} alt="预览" className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl" />
|
||||
<button onClick={() => setPreviewUrl(null)} className="absolute -top-3 -right-3 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-slate-100">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Lightbox src={previewUrl} onClose={() => setPreviewUrl(null)} />
|
||||
|
||||
{/* 浮动生图进度指示器 */}
|
||||
{genCount.total > 0 && (
|
||||
@ -733,13 +706,6 @@ export default function ProductPage() {
|
||||
|
||||
{/* Hidden image upload input */}
|
||||
<input ref={imageUploadRef} type="file" accept="image/*" className="hidden" onChange={handleImageSelected} />
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-lg text-xs shadow-lg z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -18,6 +18,104 @@ const adminPages: { key: Page; label: string }[] = [
|
||||
{ key: 'admin-configs', label: '系统配置' },
|
||||
]
|
||||
|
||||
// 统一的导航项组件
|
||||
function NavItem({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-secondary text-white font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 子导航项
|
||||
function SubNavItem({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-secondary/10 text-secondary font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 分组标题
|
||||
function GroupLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 可折叠分组
|
||||
function NavGroup({
|
||||
icon,
|
||||
label,
|
||||
active,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
active: boolean
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-secondary text-white font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<span className={`transition-transform text-xs ${expanded ? 'rotate-90' : ''}`}>▶</span>
|
||||
</button>
|
||||
{expanded && <div className="ml-2 space-y-1 mt-1">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
current,
|
||||
onNavigate,
|
||||
@ -54,155 +152,84 @@ export default function Sidebar({
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
<NavItem
|
||||
active={current === 'overview'}
|
||||
icon="📊"
|
||||
label="工作台首页"
|
||||
onClick={() => onNavigate('overview')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
|
||||
current === 'overview'
|
||||
? 'bg-secondary text-white font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span>📊</span>
|
||||
<span>工作台首页</span>
|
||||
</button>
|
||||
/>
|
||||
|
||||
<div className="border-t border-border-subtle my-2" />
|
||||
<GroupLabel label="业务场景" />
|
||||
|
||||
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
|
||||
业务场景
|
||||
</div>
|
||||
|
||||
<button
|
||||
<NavItem
|
||||
active={current === 'inquiry'}
|
||||
icon="📋"
|
||||
label="询价发布助手"
|
||||
onClick={() => onNavigate('inquiry')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
|
||||
current === 'inquiry'
|
||||
? 'bg-secondary text-white font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span>📋</span>
|
||||
<span>询价发布助手</span>
|
||||
</button>
|
||||
/>
|
||||
|
||||
{[
|
||||
{ id: 'image', key: 'image' as Page, label: '商品生图助手', icon: '🖼️' },
|
||||
{ id: 'product', key: 'product' as Page, label: '商品上架助手', icon: '📦' },
|
||||
{ id: 'comparison', key: 'comparison' as Page, label: '智能比价助手', icon: '⚖️' },
|
||||
].map((scene) => (
|
||||
<button
|
||||
<NavItem
|
||||
key={scene.id}
|
||||
active={current === scene.key}
|
||||
icon={scene.icon}
|
||||
label={scene.label}
|
||||
onClick={() => onNavigate(scene.key)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
|
||||
current === scene.key
|
||||
? 'bg-secondary text-white font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span>{scene.icon}</span>
|
||||
<span>{scene.label}</span>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="border-t border-border-subtle my-2" />
|
||||
<GroupLabel label="系统管理" />
|
||||
|
||||
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
|
||||
系统管理
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleGroup('system')}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
|
||||
['report-center', 'rule-center', 'system-monitor', 'integrations'].includes(current)
|
||||
? 'bg-secondary text-white font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>⚙️</span>
|
||||
<span>系统管理</span>
|
||||
</div>
|
||||
<span className={`transition-transform text-xs ${expandedGroups.system ? 'rotate-90' : ''}`}>▶</span>
|
||||
</button>
|
||||
{expandedGroups.system && (
|
||||
<div className="ml-2 space-y-1 mt-1">
|
||||
<button
|
||||
onClick={() => onNavigate('report-center')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
current === 'report-center'
|
||||
? 'bg-secondary/10 text-secondary font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
<span>报告中心</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('rule-center')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
current === 'rule-center'
|
||||
? 'bg-secondary/10 text-secondary font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
<span>规则中心</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('system-monitor')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
current === 'system-monitor'
|
||||
? 'bg-secondary/10 text-secondary font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
<span>系统监控</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NavGroup
|
||||
icon="⚙️"
|
||||
label="系统管理"
|
||||
active={['report-center', 'rule-center', 'system-monitor'].includes(current)}
|
||||
expanded={expandedGroups.system}
|
||||
onToggle={() => toggleGroup('system')}
|
||||
>
|
||||
<SubNavItem
|
||||
active={current === 'report-center'}
|
||||
label="报告中心"
|
||||
onClick={() => onNavigate('report-center')}
|
||||
/>
|
||||
<SubNavItem
|
||||
active={current === 'rule-center'}
|
||||
label="规则中心"
|
||||
onClick={() => onNavigate('rule-center')}
|
||||
/>
|
||||
<SubNavItem
|
||||
active={current === 'system-monitor'}
|
||||
label="系统监控"
|
||||
onClick={() => onNavigate('system-monitor')}
|
||||
/>
|
||||
</NavGroup>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="border-t border-border-subtle my-2" />
|
||||
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
|
||||
运营管理
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleGroup('admin')}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
|
||||
isInAdmin
|
||||
? 'bg-secondary text-white font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>🔧</span>
|
||||
<span>运营后台</span>
|
||||
</div>
|
||||
<span className={`transition-transform text-xs ${expandedGroups.admin ? 'rotate-90' : ''}`}>▶</span>
|
||||
</button>
|
||||
{expandedGroups.admin && (
|
||||
<div className="ml-2 space-y-1 mt-1">
|
||||
{adminPages.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => onNavigate(p.key)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
current === p.key
|
||||
? 'bg-secondary/10 text-secondary font-semibold'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
<span>{p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<GroupLabel label="运营管理" />
|
||||
<NavGroup
|
||||
icon="🔧"
|
||||
label="运营后台"
|
||||
active={isInAdmin}
|
||||
expanded={expandedGroups.admin}
|
||||
onToggle={() => toggleGroup('admin')}
|
||||
>
|
||||
{adminPages.map((p) => (
|
||||
<SubNavItem
|
||||
key={p.key}
|
||||
active={current === p.key}
|
||||
label={p.label}
|
||||
onClick={() => onNavigate(p.key)}
|
||||
/>
|
||||
))}
|
||||
</NavGroup>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
34
apps/web/src/components/ui/Lightbox.tsx
Normal file
34
apps/web/src/components/ui/Lightbox.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
interface LightboxProps {
|
||||
src: string | null
|
||||
alt?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function Lightbox({ src, alt = '预览', onClose }: LightboxProps) {
|
||||
if (!src) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-8 animate-[fadeIn_0.15s_ease-out]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="relative max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute -top-3 -right-3 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
apps/web/src/components/ui/Modal.tsx
Normal file
79
apps/web/src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useCallback } from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
width?: string // default 'w-[520px]'
|
||||
loading?: boolean // disables close on backdrop click
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
width = 'w-[520px]',
|
||||
loading = false,
|
||||
}: ModalProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !loading) onClose()
|
||||
},
|
||||
[onClose, loading],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20 animate-[fadeIn_0.15s_ease-out]"
|
||||
onClick={() => !loading && onClose()}
|
||||
>
|
||||
<div
|
||||
className={`bg-white rounded-xl shadow-2xl ${width} max-h-[calc(100vh-120px)] flex flex-col transform animate-[slideUp_0.2s_ease-out]`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center flex-shrink-0">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-on-surface-variant mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 overflow-y-auto flex-1">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="p-4 border-t border-border-subtle flex gap-2 justify-end flex-shrink-0">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
apps/web/src/components/ui/Skeleton.tsx
Normal file
68
apps/web/src/components/ui/Skeleton.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
// 通用骨架屏基础块
|
||||
function SkeletonBlock({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded bg-gradient-to-r from-slate-100 via-slate-200 to-slate-100 bg-[length:200%_100%] animate-[shimmer_1.5s_ease-in-out_infinite] ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 文本行骨架
|
||||
export function SkeletonText({ lines = 3, className = '' }: { lines?: number; className?: string }) {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<SkeletonBlock
|
||||
key={i}
|
||||
className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 表格骨架
|
||||
export function SkeletonTable({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="bg-white border border-border-subtle rounded-xl overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="bg-slate-50 border-b border-border-subtle px-4 py-3 flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<SkeletonBlock key={`h-${i}`} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
{/* 行 */}
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="px-4 py-3 border-b border-border-subtle flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<SkeletonBlock key={`${r}-${c}`} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 卡片骨架
|
||||
export function SkeletonCard({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`bg-white border border-border-subtle rounded-xl p-6 space-y-4 ${className}`}>
|
||||
<SkeletonBlock className="h-4 w-1/3" />
|
||||
<SkeletonBlock className="h-8 w-1/2" />
|
||||
<SkeletonBlock className="h-3 w-2/3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 指标卡片行骨架(用于 Overview 等页面的顶部统计行)
|
||||
export function SkeletonMetricRow({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
apps/web/src/components/ui/Toast.tsx
Normal file
74
apps/web/src/components/ui/Toast.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, type?: ToastType) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) {
|
||||
// fallback: 返回空函数,避免未包裹 Provider 时报错
|
||||
return { showToast: (msg: string) => console.warn('ToastProvider not mounted:', msg) }
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
const TYPE_STYLES: Record<ToastType, { bg: string; icon: string }> = {
|
||||
success: { bg: 'bg-emerald-600', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
error: { bg: 'bg-rose-600', icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
warning: { bg: 'bg-amber-500', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
|
||||
info: { bg: 'bg-slate-800', icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const idRef = useRef(0)
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = ++idRef.current
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 2500)
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{/* Toast 容器 */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col-reverse gap-2 pointer-events-none">
|
||||
{toasts.map((t) => {
|
||||
const style = TYPE_STYLES[t.type]
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`${style.bg} text-white px-5 py-2.5 rounded-lg text-sm shadow-lg pointer-events-auto flex items-center gap-2 animate-[slideUp_0.2s_ease-out] min-w-[200px] max-w-[420px]`}
|
||||
onClick={() => dismiss(t.id)}
|
||||
>
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={style.icon} />
|
||||
</svg>
|
||||
<span className="truncate">{t.message}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
@ -10,6 +10,8 @@ const config: Config = {
|
||||
'border-subtle': '#E2E8F0',
|
||||
'success-green': '#10B981',
|
||||
'error-red': '#EF4444',
|
||||
'info-blue': '#3B82F6',
|
||||
'warning-amber': '#F59E0B',
|
||||
background: '#f7f9fb',
|
||||
'surface-white': '#FFFFFF',
|
||||
'surface-container': '#eceef0',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user