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:
Sisyphus 2026-07-16 15:46:37 +08:00
parent 4d5521e365
commit 68f6fc5c16
20 changed files with 1228 additions and 626 deletions

178
AGENTS.md
View File

@ -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 模式

View File

@ -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'),
}
)

View File

@ -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')

View File

@ -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) {

View File

@ -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; }
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
)
}

View 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
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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',