forked from lunanting/cangku
Compare commits
1 Commits
main
...
codex/ai-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8943e698 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -54,7 +54,5 @@ tmall-board-speed-check.json
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/uploads/
|
||||
.remote-access/
|
||||
.remote-access.backup-*/
|
||||
.workbuddy/
|
||||
**/node_modules
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# AI 开发协作入口
|
||||
|
||||
开始修改、提交、推送或合并前,必须阅读并遵守 [仓库提交与合并规范](./仓库提交与合并规范.md)。准备更新生产服务器时,还必须阅读并遵守 [服务器部署规范](./服务器部署规范.md)。这些文件是本仓库多人协作的具体流程,不可仅凭本摘要跳过全文。
|
||||
开始修改、提交、推送或合并前,必须阅读并遵守 [仓库提交与合并规范](./仓库提交与合并规范.md)。该文件是本仓库多人协作的具体流程,不可仅凭本摘要跳过全文。
|
||||
|
||||
- 每个任务使用独立目录和唯一任务分支,默认前缀 `codex/`;不得共享目录并行编辑。
|
||||
- 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。
|
||||
@ -8,8 +8,6 @@
|
||||
- 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。
|
||||
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;独立审核与最新内容的验证通过后由维护者合并。
|
||||
- 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。
|
||||
- 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。
|
||||
- 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。
|
||||
- 最终交付报告实际分支、提交 SHA、验证结果及 PR 状态,不能将“已推送”报告为“已合并”。
|
||||
|
||||
这些文档不代表服务端保护已启用。任何 AI 工具无法自动读取规则时,由使用它的开发者主动提供全文。
|
||||
|
||||
26
README.md
26
README.md
@ -2,21 +2,33 @@
|
||||
|
||||
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
|
||||
|
||||
保留仪表盘、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、店铺体检、客服质检、上架测算、AI 买家秀和客户端管理等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||
|
||||
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
|
||||
已删除市场数据整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
|
||||
|
||||
## 本地启动
|
||||
|
||||
需要 Node.js 22。团队开发机器无需安装或初始化 MySQL、Redis 及第三方数据库;使用管理员发放的部署 Key,一次注册后自动连接中央系统数据库、公网 TLS Redis 和第三方数据库。完整步骤及安全边界见 [远程系统数据库部署](./remote-access/README.md)。
|
||||
需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。
|
||||
|
||||
```bash
|
||||
npm --prefix backend ci
|
||||
npm --prefix frontend ci
|
||||
npm run remote:dev
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
|
||||
填写 `backend/.env` 中的连接信息与密钥,分别在两个终端启动:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端默认访问 http://localhost:5173,API 默认代理到 http://localhost:4001,可用 `VITE_API_PROXY_TARGET` 覆盖。首次启动按照安装与设置向导初始化系统。
|
||||
|
||||
## 验证与构建
|
||||
|
||||
@ -27,10 +39,10 @@ npm --prefix frontend run check
|
||||
|
||||
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
|
||||
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
|
||||
## 参与修改
|
||||
|
||||
开始开发前请阅读 [仓库提交与合并规范(多用户与 AI 开发必读)](./仓库提交与合并规范.md)。准备更新生产服务器时必须继续阅读 [服务器部署规范(开发者与 AI 必读)](./服务器部署规范.md)。AI 工具的规则入口为 [AGENTS.md](./AGENTS.md)。每个任务使用独立目录和分支,通过 PR、同步验证和独立审核合并,禁止强推或覆盖其他开发者的工作。
|
||||
开始开发前请阅读 [仓库提交与合并规范(多用户与 AI 开发必读)](./仓库提交与合并规范.md)。AI 工具的规则入口为 [AGENTS.md](./AGENTS.md)。每个任务使用独立目录和分支,通过 PR、同步验证和独立审核合并,禁止强推或覆盖其他开发者的工作。
|
||||
|
||||
所有用户均可 Fork 仓库、创建分支并提交 Pull Request。直接推送本仓库需要仓库所有者授予协作者写权限。提交时请说明修改和验证结果,勿提交 `.env`、密码、令牌、数据库备份或真实业务数据。
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
.env
|
||||
npm-debug.log*
|
||||
coverage
|
||||
runtime
|
||||
@ -116,11 +116,8 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
||||
# 两套系统库 Redis 都不可用时,才使用下面的 .env 配置兜底。
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=6379
|
||||
REDIS_USERNAME=
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_TLS=false
|
||||
REDIS_TLS_SERVER_NAME=
|
||||
# 默认 stored 表示自动选择系统库 Redis;也可临时指定 default/changzhou/env。
|
||||
REDIS_RUNTIME_PROFILE=stored
|
||||
CHANGZHOU_REDIS_SERVER_IPS=192.168.150.234
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4090
|
||||
CMD ["node", "src/index.js"]
|
||||
1
backend/package-lock.json
generated
1
backend/package-lock.json
generated
@ -28,7 +28,6 @@
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.6.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "8.17.2",
|
||||
"playwright": "^1.60.0",
|
||||
"sharp": "^0.33.5",
|
||||
"uuid": "^9.0.1",
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"syntax": "node scripts/check-syntax.js",
|
||||
"test": "node --test test/*.test.js",
|
||||
"check": "npm run syntax && npm test",
|
||||
"alert:check": "node src/services/alertChecker.js"
|
||||
"alert:check": "node src/services/alertChecker.js",
|
||||
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.975.0",
|
||||
@ -31,7 +32,6 @@
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.6.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "8.17.2",
|
||||
"playwright": "^1.60.0",
|
||||
"sharp": "^0.33.5",
|
||||
"uuid": "^9.0.1",
|
||||
|
||||
404
backend/scripts/seed_product_tag_config_from_xlsx.js
Normal file
404
backend/scripts/seed_product_tag_config_from_xlsx.js
Normal file
@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要
|
||||
*
|
||||
* 解析口径(2026-09-11 评审确认):
|
||||
* - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档)
|
||||
* - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签)
|
||||
* - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略
|
||||
*
|
||||
* 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql,
|
||||
* 或由 ensureProductTagConfigSchema() 自动建表)。
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const database = require('../src/config/database');
|
||||
|
||||
const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款'];
|
||||
const IGNORED_CATEGORIES = ['-', ''];
|
||||
const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级'];
|
||||
|
||||
// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。
|
||||
// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目;
|
||||
// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。
|
||||
// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。
|
||||
// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表
|
||||
// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表
|
||||
const MANUAL_CATEGORY_MAP = {
|
||||
'其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] },
|
||||
'办公家具': { family: 'office', exclude: ['文具边柜'] },
|
||||
};
|
||||
|
||||
// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致)
|
||||
const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } };
|
||||
const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } };
|
||||
|
||||
// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名
|
||||
function stripCategoryPrefix(value) {
|
||||
return String(value).replace(/^.*?\s+-\s+/, '').trim();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { file: '', printSql: false, dryRun: false };
|
||||
argv.slice(2).forEach((item) => {
|
||||
if (item.startsWith('--file=')) args.file = item.slice('--file='.length);
|
||||
else if (item === '--print-sql') args.printSql = true;
|
||||
else if (item === '--dry-run') args.dryRun = true;
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。
|
||||
// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1)
|
||||
// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13
|
||||
// - 整数 12000 → 12000
|
||||
function normalizeThreshold(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return Math.round(num * 100) / 100;
|
||||
}
|
||||
|
||||
function sqlText(value) {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function sqlNumber(value) {
|
||||
return value === null || value === undefined ? 'NULL' : String(value);
|
||||
}
|
||||
|
||||
function readWorkbook(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在:${filePath}`);
|
||||
}
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null });
|
||||
return { sheetName, rows };
|
||||
}
|
||||
|
||||
function parseScaleThresholds(rows) {
|
||||
const list = [];
|
||||
const skipped = [];
|
||||
for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const category = text(row[BLOCK1.cols.category]);
|
||||
if (IGNORED_CATEGORIES.includes(category)) {
|
||||
if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` });
|
||||
continue;
|
||||
}
|
||||
const scaleTag = text(row[BLOCK1.cols.scaleTag]);
|
||||
if (!SCALE_TAG_ORDER.includes(scaleTag)) {
|
||||
skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` });
|
||||
continue;
|
||||
}
|
||||
const raw = {
|
||||
performanceMin: row[BLOCK1.cols.pMin],
|
||||
performanceMax: row[BLOCK1.cols.pMax],
|
||||
salesMin: row[BLOCK1.cols.sMin],
|
||||
salesMax: row[BLOCK1.cols.sMax],
|
||||
};
|
||||
const rounded = {
|
||||
performanceMin: normalizeThreshold(raw.performanceMin),
|
||||
performanceMax: normalizeThreshold(raw.performanceMax),
|
||||
salesMin: normalizeThreshold(raw.salesMin),
|
||||
salesMax: normalizeThreshold(raw.salesMax),
|
||||
};
|
||||
// 只记录「确实被取整」的溢出值,便于核对(决策 5)
|
||||
Object.entries(raw).forEach(([key, value]) => {
|
||||
const before = Number(value);
|
||||
if (!Number.isFinite(before)) return;
|
||||
if (rounded[key] !== before) {
|
||||
skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true });
|
||||
}
|
||||
});
|
||||
list.push({
|
||||
// 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值
|
||||
displayName: category,
|
||||
scaleTag,
|
||||
...rounded,
|
||||
priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
});
|
||||
}
|
||||
return { list, skipped };
|
||||
}
|
||||
|
||||
// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库)
|
||||
async function loadDbCategories() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.PLANNING_PUBLIC_DB_HOST,
|
||||
port: Number(process.env.PLANNING_PUBLIC_DB_PORT),
|
||||
user: process.env.PLANNING_PUBLIC_DB_USER,
|
||||
password: process.env.PLANNING_PUBLIC_DB_PASSWORD,
|
||||
database: process.env.PLANNING_PUBLIC_DB_NAME,
|
||||
connectTimeout: 20000,
|
||||
});
|
||||
const [rows] = await connection.query(
|
||||
`SELECT TRIM(category) AS category, COUNT(*) AS skuCount
|
||||
FROM jst_sku
|
||||
WHERE category IS NOT NULL AND TRIM(category) <> ''
|
||||
GROUP BY TRIM(category)`
|
||||
);
|
||||
await connection.end();
|
||||
return rows.map((row) => ({
|
||||
full: row.category,
|
||||
short: stripCategoryPrefix(row.category),
|
||||
skuCount: Number(row.skuCount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「配置短名」展开为「数据库完整品类名」。
|
||||
* 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌),
|
||||
* 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。
|
||||
*/
|
||||
function expandCategories(configRows, dbCategories) {
|
||||
const matched = [];
|
||||
const ambiguous = [];
|
||||
const missing = [];
|
||||
|
||||
configRows.forEach((row) => {
|
||||
const shortName = row.displayName;
|
||||
|
||||
// 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目)
|
||||
if (MANUAL_CATEGORY_MAP[shortName]) {
|
||||
const rule = MANUAL_CATEGORY_MAP[shortName];
|
||||
let resolved = [];
|
||||
if (Array.isArray(rule)) {
|
||||
// 兼容:显式 DB 短名列表
|
||||
rule.forEach((targetShort) => {
|
||||
const hits = dbCategories.filter((item) => item.short === targetShort);
|
||||
if (hits.length) hits.forEach((hit) => resolved.push(hit));
|
||||
else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`);
|
||||
});
|
||||
} else if (rule && rule.family === 'sofa') {
|
||||
resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short));
|
||||
} else if (rule && rule.family === 'office') {
|
||||
resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short));
|
||||
}
|
||||
if (!resolved.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
resolved.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 剥离前缀后同名
|
||||
let hits = dbCategories.filter((item) => item.short === shortName);
|
||||
// 3) 包含匹配兜底
|
||||
if (!hits.length) {
|
||||
hits = dbCategories.filter((item) => (
|
||||
item.short.includes(shortName) || shortName.includes(item.short)
|
||||
));
|
||||
}
|
||||
|
||||
if (!hits.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
hits.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
if (hits.length > 1) ambiguous.push({ shortName, targets: hits });
|
||||
}
|
||||
});
|
||||
|
||||
return { matched, ambiguous, missing };
|
||||
}
|
||||
|
||||
function parseSabcMappings(rows) {
|
||||
const list = [];
|
||||
// H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …),
|
||||
// XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null,
|
||||
// 因此需要向下填充,否则只能解析出 5 条而不是 25 条。
|
||||
let carriedScaleTag = '';
|
||||
for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const ownScaleTag = text(row[BLOCK2.cols.scaleTag]);
|
||||
if (ownScaleTag) carriedScaleTag = ownScaleTag;
|
||||
|
||||
const scaleTag = carriedScaleTag;
|
||||
const profitLevel = text(row[BLOCK2.cols.profitLevel]);
|
||||
const sabcTag = text(row[BLOCK2.cols.sabc]);
|
||||
if (!scaleTag || !profitLevel || !sabcTag) continue;
|
||||
list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function buildScaleInsertSql(list) {
|
||||
if (!list.length) return '-- (无规模阈值数据)';
|
||||
const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, `
|
||||
+ `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, `
|
||||
+ `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`);
|
||||
return `INSERT INTO product_scale_thresholds\n`
|
||||
+ ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n'
|
||||
+ `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n`
|
||||
+ ' display_name = VALUES(display_name),\n'
|
||||
+ ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n'
|
||||
+ ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n'
|
||||
+ ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);';
|
||||
}
|
||||
|
||||
const PROFIT_LEVEL_RATE = {
|
||||
一级: 0.18,
|
||||
二级: 0.16,
|
||||
三级: 0.12,
|
||||
四级: 0.08,
|
||||
五级: 0.08,
|
||||
};
|
||||
|
||||
async function writeScaleThresholds(list) {
|
||||
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(
|
||||
row.category, row.displayName, row.scaleTag,
|
||||
row.performanceMin, row.performanceMax, row.salesMin, row.salesMax,
|
||||
row.priority, row.sort, 1
|
||||
);
|
||||
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_scale_thresholds
|
||||
(category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function writeProfitLevels() {
|
||||
await database.local.query('DELETE FROM product_profit_levels');
|
||||
const values = [];
|
||||
const placeholders = PROFIT_LEVELS.map((levelCode, index) => {
|
||||
const rate = PROFIT_LEVEL_RATE[levelCode];
|
||||
values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return PROFIT_LEVELS.length;
|
||||
}
|
||||
|
||||
async function writeSabcMappings(list) {
|
||||
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.file) {
|
||||
console.error('缺少 --file=<xlsx 路径> 参数');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { sheetName, rows } = readWorkbook(args.file);
|
||||
console.log(`读取工作簿:${args.file}`);
|
||||
console.log(`Sheet:${sheetName},共 ${rows.length} 行`);
|
||||
|
||||
const { list: configRows, skipped } = parseScaleThresholds(rows);
|
||||
const mappings = parseSabcMappings(rows);
|
||||
|
||||
console.log(`\n解析配置表:`);
|
||||
console.log(` 规模阈值配置行:${configRows.length}(按「配置短名 × 规模标签」计)`);
|
||||
console.log(` SABC 映射 :${mappings.length} 条`);
|
||||
|
||||
if (skipped.length) {
|
||||
console.log(`\n跳过 / 修正明细(${skipped.length} 条):`);
|
||||
skipped.forEach((item) => console.log(` 第 ${item.rowNo} 行:${item.reason}`));
|
||||
}
|
||||
|
||||
// ---- 品类名展开:配置短名 → jst_sku.category 完整名 ----
|
||||
const dbCategories = await loadDbCategories();
|
||||
const { matched: thresholds, ambiguous, missing } = expandCategories(configRows, dbCategories);
|
||||
|
||||
console.log(`\n品类名映射:`);
|
||||
console.log(` 数据库品类数:${dbCategories.length}`);
|
||||
console.log(` 展开后阈值行:${thresholds.length}(配置行 ${configRows.length} → DB 品类行)`);
|
||||
if (ambiguous.length) {
|
||||
// 同一个短名会出现在多个规模标签下,按短名去重后再报告
|
||||
const byShortName = new Map();
|
||||
ambiguous.forEach((item) => {
|
||||
if (!byShortName.has(item.shortName)) byShortName.set(item.shortName, item.targets);
|
||||
});
|
||||
console.log(`\n ⚠️ 一对多(${byShortName.size} 个配置名对应多个 DB 品类,已全部归入同一档阈值):`);
|
||||
byShortName.forEach((targets, shortName) => {
|
||||
console.log(` ${shortName}`);
|
||||
targets.forEach((t) => console.log(` → ${t.full} (${t.skuCount} SKU)`));
|
||||
});
|
||||
}
|
||||
if (missing.length) {
|
||||
console.log(`\n ❌ 匹配不到(${missing.length} 个,这些配置行不会生效,需在 MANUAL_CATEGORY_MAP 中补映射):`);
|
||||
[...new Set(missing.map((m) => m.displayName))].forEach((name) => console.log(` - ${name}`));
|
||||
}
|
||||
|
||||
const byTag = SCALE_TAG_ORDER.map((tag) => ({
|
||||
tag,
|
||||
count: thresholds.filter((row) => row.scaleTag === tag).length,
|
||||
}));
|
||||
console.log(`\n 按规模标签分布:${byTag.map((item) => `${item.tag} ${item.count}`).join(' / ')}`);
|
||||
console.log(` 展开后品类数:${new Set(thresholds.map((row) => row.category)).size}`);
|
||||
|
||||
if (args.printSql) {
|
||||
console.log('\n---------- 规模标签阈值 SQL ----------');
|
||||
console.log(buildScaleInsertSql(thresholds));
|
||||
console.log('\n(利润标签与 SABC 映射的 SQL 见 sql/20260911_create_product_tag_config_system_mysql.sql)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('\n--dry-run:未写入数据库。');
|
||||
return;
|
||||
}
|
||||
|
||||
const writtenThresholds = await writeScaleThresholds(thresholds);
|
||||
const writtenLevels = await writeProfitLevels();
|
||||
const writtenMappings = await writeSabcMappings(mappings);
|
||||
console.log('\n✅ 导入完成:');
|
||||
console.log(` product_scale_thresholds : ${writtenThresholds} 行`);
|
||||
console.log(` product_profit_levels : ${writtenLevels} 行`);
|
||||
console.log(` product_sabc_mappings : ${writtenMappings} 条`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => database.close && database.close())
|
||||
.catch((error) => {
|
||||
console.error('导入失败:', error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
218
backend/scripts/sync_jian_sun_product_analysis_tables.js
Normal file
218
backend/scripts/sync_jian_sun_product_analysis_tables.js
Normal file
@ -0,0 +1,218 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const sql = require('mssql');
|
||||
|
||||
const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10));
|
||||
const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '')
|
||||
? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA
|
||||
: 'app';
|
||||
|
||||
const sourceConfig = {
|
||||
server: process.env.JIAN_SUN_DB_HOST,
|
||||
port: Number(process.env.JIAN_SUN_DB_PORT || 1433),
|
||||
database: process.env.JIAN_SUN_DB_NAME || 'JianSun',
|
||||
user: process.env.JIAN_SUN_DB_USER,
|
||||
password: process.env.JIAN_SUN_DB_PASSWORD,
|
||||
options: { encrypt: false, trustServerCertificate: true },
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const targetConfig = {
|
||||
server: process.env.CLOUD_DB_PUBLIC_HOST,
|
||||
port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433),
|
||||
database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica',
|
||||
user: process.env.CLOUD_DB_PUBLIC_USER,
|
||||
password: process.env.CLOUD_DB_PUBLIC_PASSWORD,
|
||||
options: {
|
||||
encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true',
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const tables = [
|
||||
{
|
||||
name: 'pdd_DataActivityRecords',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[shopName] varchar(40) NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[business_type_str] varchar(100) NULL,
|
||||
[activity_name] varchar(100) NULL,
|
||||
[activity_id] int NULL,
|
||||
[goods_name] varchar(100) NULL,
|
||||
[goods_id] bigint NULL,
|
||||
[hd_thumb_url] varchar(100) NULL,
|
||||
[enroll_time ] bigint NULL,
|
||||
[min_activity_sku_price] decimal(38, 2) NULL,
|
||||
[max_activity_sku_price] decimal(38, 2) NULL,
|
||||
[activity_goods_start_time] bigint NULL,
|
||||
[activity_goods_end_time] bigint NULL,
|
||||
[status] int NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['business_type_str', sql.VarChar(100), true],
|
||||
['activity_name', sql.VarChar(100), true],
|
||||
['activity_id', sql.Int, true],
|
||||
['goods_name', sql.VarChar(100), true],
|
||||
['goods_id', sql.BigInt, true],
|
||||
['hd_thumb_url', sql.VarChar(100), true],
|
||||
['enroll_time ', sql.BigInt, true],
|
||||
['min_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['max_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['activity_goods_start_time', sql.BigInt, true],
|
||||
['activity_goods_end_time', sql.BigInt, true],
|
||||
['status', sql.Int, true],
|
||||
],
|
||||
indexColumn: 'goods_id',
|
||||
},
|
||||
{
|
||||
name: 'pdd_GoodsQualities',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[avgDescRevScr1m] float NULL,
|
||||
[qltyRfndOrdrCntRto1m] float NULL,
|
||||
[ptHelpPzRate30d] float NULL,
|
||||
[midRto30d] float NULL,
|
||||
[goodsId] bigint NULL,
|
||||
[displayMallScoreRk] int NULL,
|
||||
[rankAvgDescRevScr1mStage] int NULL,
|
||||
[rankAvgDescRevScr1mStplStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStplStage] int NULL,
|
||||
[rankPtHelpPzRate30dStage] int NULL,
|
||||
[rankPtHelpPzRate30dStplStage] int NULL,
|
||||
[rankComscoreStage ] int NULL,
|
||||
[rankComscoreStplStage] int NULL,
|
||||
[goodsScoreRkStage] int NULL,
|
||||
[goodsScoreRkStplStage] int NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[shopName] varchar(40) NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['avgDescRevScr1m', sql.Float, true],
|
||||
['qltyRfndOrdrCntRto1m', sql.Float, true],
|
||||
['ptHelpPzRate30d', sql.Float, true],
|
||||
['midRto30d', sql.Float, true],
|
||||
['goodsId', sql.BigInt, true],
|
||||
['displayMallScoreRk', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStage', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStplStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStplStage', sql.Int, true],
|
||||
['rankComscoreStage ', sql.Int, true],
|
||||
['rankComscoreStplStage', sql.Int, true],
|
||||
['goodsScoreRkStage', sql.Int, true],
|
||||
['goodsScoreRkStplStage', sql.Int, true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
],
|
||||
indexColumn: 'goodsId',
|
||||
},
|
||||
];
|
||||
|
||||
function qualified(schema, tableName) {
|
||||
return `[${schema}].[${tableName}]`;
|
||||
}
|
||||
|
||||
function assertConfig(config, label) {
|
||||
const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]);
|
||||
if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
async function ensureTargetTable(pool, definition) {
|
||||
const tableName = qualified(targetSchema, definition.name);
|
||||
const pkName = `PK_${definition.name}`;
|
||||
const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`;
|
||||
await pool.request().batch(`
|
||||
IF OBJECT_ID(N'${tableName}', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE ${tableName} (
|
||||
${definition.createColumns},
|
||||
CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id])
|
||||
);
|
||||
END;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}')
|
||||
BEGIN
|
||||
CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
function createBulkTable(definition) {
|
||||
const table = new sql.Table(definition.name);
|
||||
table.schema = targetSchema;
|
||||
table.create = false;
|
||||
definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable }));
|
||||
return table;
|
||||
}
|
||||
|
||||
async function syncTable(sourcePool, targetPool, definition) {
|
||||
await ensureTargetTable(targetPool, definition);
|
||||
const sourceName = qualified('dbo', definition.name);
|
||||
const targetName = qualified(targetSchema, definition.name);
|
||||
const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', ');
|
||||
const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`);
|
||||
let lastId = Number(targetState.recordset[0].maxId || 0);
|
||||
let copied = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await sourcePool.request()
|
||||
.input('lastId', sql.Int, lastId)
|
||||
.query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`);
|
||||
const rows = result.recordset || [];
|
||||
if (!rows.length) break;
|
||||
|
||||
const bulkTable = createBulkTable(definition);
|
||||
rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name])));
|
||||
await targetPool.request().bulk(bulkTable);
|
||||
lastId = Number(rows[rows.length - 1].id);
|
||||
copied += rows.length;
|
||||
console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`);
|
||||
}
|
||||
|
||||
const [sourceStats, targetStats] = await Promise.all([
|
||||
sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`),
|
||||
targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`),
|
||||
]);
|
||||
return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] };
|
||||
}
|
||||
|
||||
async function syncProductAnalysisReplica() {
|
||||
assertConfig(sourceConfig, 'JianSun source');
|
||||
assertConfig(targetConfig, 'JSReportReplica target');
|
||||
const [sourcePool, targetPool] = await Promise.all([
|
||||
new sql.ConnectionPool(sourceConfig).connect(),
|
||||
new sql.ConnectionPool(targetConfig).connect(),
|
||||
]);
|
||||
|
||||
try {
|
||||
const summary = {};
|
||||
for (const definition of tables) {
|
||||
summary[definition.name] = await syncTable(sourcePool, targetPool, definition);
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
} finally {
|
||||
await Promise.all([sourcePool.close(), targetPool.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
syncProductAnalysisReplica().catch((error) => {
|
||||
console.error('[product-analysis-sync] failed:', error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncProductAnalysisReplica,
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
-- 商品监控配套:部门商品标签规则 + 部门SKU负责人配置
|
||||
-- 目标库:系统库 shujuku3_system(MySQL / MariaDB)
|
||||
-- 创建日期:2026-09-11
|
||||
--
|
||||
-- 说明:
|
||||
-- 1) product_tag_rules 部门标签 G-S/G-A/G-B/G-C 的阈值规则(tag_scope='department')
|
||||
-- 与公司 SABC(tag_scope='company')共用一张表,靠 tag_scope 区分。
|
||||
-- ⚠️ 公司 SABC 目前由「商品标签配置」页的三张表承载,本表仅启用 department 分支。
|
||||
-- 2) department_sku_owners 部门 × SKU/SPU → 负责人 的手工维护映射
|
||||
--
|
||||
-- 本文件与 services/localSchema.js 的 ensureProductMonitoringSchema() 等价,
|
||||
-- 服务启动时会自动 CREATE TABLE IF NOT EXISTS,本文件供 DBA 手工建表使用。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签:G-S/G-A/G-B/G-C(company 分支预留 S/A/B/C/D)',
|
||||
metric_key VARCHAR(64) NOT NULL COMMENT '阈值指标:sales_qty_7d(近7天销售件数)/ sales_amount_7d(近7天销售额)',
|
||||
threshold_min DECIMAL(18,4) NULL COMMENT '区间起点(含)',
|
||||
threshold_max DECIMAL(18,4) NULL COMMENT '区间终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同 scope 内升序命中即停',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||
KEY idx_scope_enabled (tag_scope, enabled),
|
||||
KEY idx_metric (metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)';
|
||||
|
||||
-- 初始数据:部门标签四档,默认口径为近 7 天销售件数(sales_qty_7d)
|
||||
-- ⚠️ 阈值为占位默认值,需在「系统设置 → 部门商品标签配置」中按业务口径调整后生效。
|
||||
INSERT INTO product_tag_rules (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES
|
||||
('department', 'G-S', 'sales_qty_7d', 500, NULL, 1, 1),
|
||||
('department', 'G-A', 'sales_qty_7d', 200, 500, 2, 1),
|
||||
('department', 'G-B', 'sales_qty_7d', 50, 200, 3, 1),
|
||||
('department', 'G-C', 'sales_qty_7d', 0, 50, 4, 1)
|
||||
ON DUPLICATE KEY UPDATE tag_scope = tag_scope;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
department_id BIGINT UNSIGNED NOT NULL COMMENT '关联 departments.id',
|
||||
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||
user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id',
|
||||
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人;填值则为该 SPU 的负责人',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||
KEY idx_dept (department_id),
|
||||
KEY idx_spu (spu),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置';
|
||||
@ -1,14 +1,14 @@
|
||||
const {
|
||||
isRetiredDatabaseConfigName,
|
||||
isRetiredSystemConfigKey,
|
||||
pruneRetiredSidebarConfig,
|
||||
} = require('../services/retiredFeatures');
|
||||
const { isRetiredSystemConfigKey, pruneRetiredSidebarConfig } = require('../services/retiredFeatures');
|
||||
// 管理员 API
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const dayjs = require('dayjs');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const database = require('../config/database');
|
||||
@ -25,7 +25,17 @@ const {
|
||||
normalizeStorageClass,
|
||||
testObjectStorageConnection,
|
||||
uploadBase64ImageToDefaultS3,
|
||||
uploadFileToDefaultS3,
|
||||
} = require('../services/objectStorage');
|
||||
const {
|
||||
compareVersions,
|
||||
getLatestDesktopRelease,
|
||||
listDesktopReleases,
|
||||
normalizeArchitecture,
|
||||
normalizePlatform,
|
||||
publishDesktopRelease,
|
||||
validateVersion,
|
||||
} = require('../services/desktopReleaseService');
|
||||
const { listOnlineDesktopUsers } = require('../services/desktopPresenceService');
|
||||
const {
|
||||
getSystemDbConfig,
|
||||
@ -98,6 +108,10 @@ const {
|
||||
testSelectionFissionAiConfig,
|
||||
} = require('../services/selectionFissionAiSettings');
|
||||
const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService');
|
||||
const {
|
||||
getProductTagConfig,
|
||||
saveProductTagConfig,
|
||||
} = require('../services/productTagConfigService');
|
||||
|
||||
const backfillJobs = new Map();
|
||||
const productCodeImportUpload = multer({
|
||||
@ -115,6 +129,44 @@ const productCodeImportUpload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
const desktopReleaseTempDir = path.join(os.tmpdir(), 'maixiang-desktop-releases');
|
||||
fs.mkdirSync(desktopReleaseTempDir, { recursive: true });
|
||||
const desktopReleaseUpload = multer({
|
||||
dest: desktopReleaseTempDir,
|
||||
limits: {
|
||||
fileSize: 300 * 1024 * 1024,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (/\.(exe|dmg)$/i.test(String(file.originalname || ''))) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error('仅支持上传 Windows(.exe)或 macOS(.dmg)安装包'));
|
||||
},
|
||||
});
|
||||
|
||||
const uploadDesktopReleaseInstaller = (req, res, next) => {
|
||||
desktopReleaseUpload.single('installer')(req, res, (error) => {
|
||||
if (!error) return next();
|
||||
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ success: false, error: '安装包不能超过 300 MB' });
|
||||
}
|
||||
return res.status(400).json({ success: false, error: error.message || '安装包上传失败' });
|
||||
});
|
||||
};
|
||||
|
||||
async function sha256File(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
await new Promise((resolve, reject) => {
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', resolve);
|
||||
});
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
const runBackfill = async (job) => {
|
||||
try {
|
||||
const totalStepsPerDay = 21;
|
||||
@ -156,6 +208,93 @@ router.get('/desktop-presence', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/desktop-releases', async (req, res) => {
|
||||
try {
|
||||
const releases = await listDesktopReleases(req.query.limit);
|
||||
res.json({
|
||||
success: true,
|
||||
data: releases.map((release) => ({
|
||||
...release,
|
||||
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取桌面客户端发布记录失败:', error);
|
||||
res.status(500).json({ success: false, error: '获取桌面客户端发布记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/desktop-releases', uploadDesktopReleaseInstaller, async (req, res) => {
|
||||
const tempFilePath = req.file?.path;
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: '请选择需要发布的桌面客户端安装包' });
|
||||
}
|
||||
const version = validateVersion(req.body?.version);
|
||||
const originalName = String(req.file.originalname || '');
|
||||
const platform = normalizePlatform(req.body?.platform, originalName);
|
||||
const architecture = normalizeArchitecture(req.body?.architecture, originalName, platform);
|
||||
const expectedExtension = platform === 'darwin' ? '.dmg' : '.exe';
|
||||
if (!originalName.toLowerCase().endsWith(expectedExtension)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: platform === 'darwin' ? 'macOS 安装包必须为 .dmg 格式' : 'Windows 安装包必须为 .exe 格式',
|
||||
});
|
||||
}
|
||||
const latest = await getLatestDesktopRelease(platform, architecture);
|
||||
if (latest && compareVersions(version, latest.version) < 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: `发布版本 v${version} 低于当前平台最新版本 v${latest.version}`,
|
||||
});
|
||||
}
|
||||
|
||||
const fileName = platform === 'darwin'
|
||||
? `pdd-auto-sale-${version}-${architecture}.dmg`
|
||||
: `pdd-auto-sale-${version}-setup.exe`;
|
||||
const objectKey = `desktop-client/${platform}/${version}/${Date.now()}-${fileName}`;
|
||||
const mimeType = platform === 'darwin'
|
||||
? 'application/x-apple-diskimage'
|
||||
: 'application/vnd.microsoft.portable-executable';
|
||||
const [sha256, uploadResult] = await Promise.all([
|
||||
sha256File(tempFilePath),
|
||||
uploadFileToDefaultS3(tempFilePath, {
|
||||
key: objectKey,
|
||||
mimeType,
|
||||
}),
|
||||
]);
|
||||
const release = await publishDesktopRelease({
|
||||
version,
|
||||
platform,
|
||||
architecture,
|
||||
releaseNotes: String(req.body?.releaseNotes || '').trim(),
|
||||
objectKey: uploadResult.key,
|
||||
fileName,
|
||||
fileSize: req.file.size,
|
||||
sha256,
|
||||
publishedByUserId: Number(req.user?.sub || req.user?.id || 0) || null,
|
||||
publishedByName: req.user?.name || req.user?.username || req.user?.email || '',
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `${platform === 'darwin' ? 'macOS' : 'Windows'} 客户端 v${version} 已发布`,
|
||||
data: {
|
||||
...release,
|
||||
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发布桌面客户端失败:', error);
|
||||
const status = /版本号格式/.test(error.message) ? 400 : 500;
|
||||
res.status(status).json({ success: false, error: error.message || '发布桌面客户端失败' });
|
||||
} finally {
|
||||
if (tempFilePath) {
|
||||
await fs.promises.unlink(tempFilePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cache-performance', async (_req, res) => {
|
||||
const dataPayloadStats = getStatsSnapshot();
|
||||
res.json({
|
||||
@ -1211,6 +1350,40 @@ router.post('/selection-fission-ai-settings/image-generation/test', async (_req,
|
||||
|
||||
|
||||
|
||||
// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ====================
|
||||
|
||||
router.get('/product-tag-config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getProductTagConfig() });
|
||||
} catch (error) {
|
||||
console.error('获取商品标签配置失败:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取商品标签配置失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/product-tag-config', async (req, res) => {
|
||||
try {
|
||||
const result = await saveProductTagConfig(req.body || {});
|
||||
const config = await getProductTagConfig();
|
||||
res.json({
|
||||
success: true,
|
||||
message: '商品标签配置已保存到系统数据库',
|
||||
data: { ...config, saved: result },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存商品标签配置失败:', error);
|
||||
// 校验类错误回 400,方便前端直接展示原因
|
||||
const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500);
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error.message || '保存商品标签配置失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 1688 专用以图搜图 ====================
|
||||
|
||||
|
||||
@ -1449,10 +1622,12 @@ router.post('/work-order-config/test', async (req, res) => {
|
||||
const DEFAULT_DATABASE_CONFIG_NAME = 'cloud_database';
|
||||
const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public';
|
||||
const PLANNING_DATABASE_CONFIG_NAME = 'planning_database';
|
||||
const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database';
|
||||
const PROTECTED_DATABASE_CONFIG_NAMES = new Set([
|
||||
DEFAULT_DATABASE_CONFIG_NAME,
|
||||
CLOUD_DATABASE_PUBLIC_CONFIG_NAME,
|
||||
PLANNING_DATABASE_CONFIG_NAME,
|
||||
AI_BUYER_SHOW_DATABASE_CONFIG_NAME,
|
||||
]);
|
||||
|
||||
function normalizeDatabaseConfigName(rawName) {
|
||||
@ -1465,11 +1640,6 @@ function normalizeDatabaseConfigName(rawName) {
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (isRetiredDatabaseConfigName(configName)) {
|
||||
const error = new Error('该功能已下线,不能创建对应数据库配置');
|
||||
error.statusCode = 410;
|
||||
throw error;
|
||||
}
|
||||
return configName;
|
||||
}
|
||||
|
||||
@ -1479,13 +1649,6 @@ function getDefaultPortByDriver(driver) {
|
||||
return 3306;
|
||||
}
|
||||
|
||||
function normalizeDatabaseDriver(driver) {
|
||||
const normalized = String(driver || '').trim().toLowerCase();
|
||||
if (normalized === 'postgresql' || normalized === 'postgres') return 'postgres';
|
||||
if (normalized === 'mssql') return 'mssql';
|
||||
return 'mysql';
|
||||
}
|
||||
|
||||
async function getStoredDatabaseConfig(configName) {
|
||||
const normalizedName = normalizeDatabaseConfigName(configName);
|
||||
let configs = await database.local.query(
|
||||
@ -1624,9 +1787,7 @@ router.put('/database', async (req, res) => {
|
||||
}
|
||||
|
||||
// 根据数据库类型设置默认端口
|
||||
const resolvedDriver = normalizeDatabaseDriver(
|
||||
driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql')
|
||||
);
|
||||
const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql');
|
||||
const defaultPort = getDefaultPortByDriver(resolvedDriver);
|
||||
|
||||
const configData = {
|
||||
@ -1720,8 +1881,6 @@ router.post('/database/test', async (req, res) => {
|
||||
return res.status(400).json({ success: false, error: '请完整填写数据库连接信息' });
|
||||
}
|
||||
|
||||
driver = normalizeDatabaseDriver(driver);
|
||||
|
||||
if (driver === 'mssql') {
|
||||
// SQL Server 连接测试
|
||||
const sql = require('mssql');
|
||||
@ -1741,26 +1900,6 @@ router.post('/database/test', async (req, res) => {
|
||||
const pool = await sql.connect(config);
|
||||
await pool.request().query('SELECT 1 as test');
|
||||
await pool.close();
|
||||
} else if (driver === 'postgres') {
|
||||
const { Client } = require('pg');
|
||||
const client = new Client({
|
||||
host,
|
||||
port: port || 5432,
|
||||
database: dbName,
|
||||
user: username,
|
||||
password,
|
||||
ssl: ssl ? { rejectUnauthorized: false } : false,
|
||||
connectionTimeoutMillis: 10000,
|
||||
query_timeout: 10000,
|
||||
statement_timeout: 10000,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.query('SELECT 1 AS test');
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// MySQL 连接测试
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
397
backend/src/api/aiBuyerShow.js
Normal file
397
backend/src/api/aiBuyerShow.js
Normal file
@ -0,0 +1,397 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const axios = require('axios');
|
||||
const {
|
||||
buildDefaultPrompt,
|
||||
createAiBuyerShowJob,
|
||||
generateAiBuyerShowReviews,
|
||||
getAiBuyerShowHistoryDetail,
|
||||
getAiBuyerShowJob,
|
||||
getQueueStats,
|
||||
getWorkerRuntimeStatus,
|
||||
listAiBuyerShowHistory,
|
||||
listAiBuyerShowJobs,
|
||||
testAiBuyerShowDatabase,
|
||||
} = require('../services/aiBuyerShowService');
|
||||
const { buildBuyerShowPrompt } = require('../services/aiBuyerShowReviewService');
|
||||
const {
|
||||
createSeedPack,
|
||||
deleteSeedPack,
|
||||
getSeedPackDownload,
|
||||
listSeedPacks,
|
||||
MAX_SEED_PACK_FILE_BYTES,
|
||||
} = require('../services/aiBuyerShowSeedPackService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
files: 10,
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (String(file.mimetype || '').startsWith('image/')) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error('仅支持上传图片文件'));
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFields = upload.fields([
|
||||
{ name: 'productImages', maxCount: 5 },
|
||||
{ name: 'sceneReferenceImages', maxCount: 4 },
|
||||
]);
|
||||
|
||||
function runUpload(req, res, next) {
|
||||
uploadFields(req, res, (error) => {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||
res.status(413).json({ success: false, error: '图片不能超过 20MB' });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ success: false, error: error.message || '图片上传失败' });
|
||||
});
|
||||
}
|
||||
|
||||
const seedPackUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: MAX_SEED_PACK_FILE_BYTES,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
const isZip = String(file.mimetype || '') === 'application/zip'
|
||||
|| String(file.mimetype || '') === 'application/x-zip-compressed'
|
||||
|| /\.zip$/i.test(String(file.originalname || ''));
|
||||
if (isZip) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error('种菜资源包仅支持上传 ZIP 文件'));
|
||||
},
|
||||
}).single('file');
|
||||
|
||||
function runSeedPackUpload(req, res, next) {
|
||||
seedPackUpload(req, res, (error) => {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||
res.status(413).json({ success: false, error: '种菜资源包 ZIP 不能超过 200MB' });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ success: false, error: error.message || '种菜资源包上传失败' });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeList(value) {
|
||||
const raw = Array.isArray(value) ? value : String(value || '').split(/[\n,,]+/);
|
||||
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function isAllowedImageUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return ['http:', 'https:'].includes(parsed.protocol) && (
|
||||
parsed.hostname.endsWith('aliyuncs.com')
|
||||
|| parsed.hostname.endsWith('volces.com')
|
||||
|| parsed.hostname.endsWith('jiansuntuzhi.cn')
|
||||
|| parsed.hostname.endsWith('yydd.ai')
|
||||
|| parsed.hostname.endsWith('jiansun.vip')
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/runtime', async (_req, res) => {
|
||||
try {
|
||||
const databaseStatus = await testAiBuyerShowDatabase();
|
||||
const queue = databaseStatus.ok ? await getQueueStats() : null;
|
||||
const worker = databaseStatus.ok ? await getWorkerRuntimeStatus(queue || {}) : {
|
||||
enabled: false,
|
||||
hasApiKey: false,
|
||||
status: 'database_unavailable',
|
||||
source: 'job-table-observation',
|
||||
lastActivityAt: null,
|
||||
lastError: '',
|
||||
};
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
database: databaseStatus,
|
||||
queue,
|
||||
worker,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀运行状态查询失败',
|
||||
data: {
|
||||
database: {
|
||||
ok: false,
|
||||
missingTables: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/prompt-preview', express.json({ limit: '1mb' }), async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
prompt: body.review
|
||||
? buildBuyerShowPrompt({
|
||||
review: body.review,
|
||||
analysis: body.analysis || {},
|
||||
style: body.buyerShowStyle,
|
||||
})
|
||||
: buildDefaultPrompt(body),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message || '提示词预览失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/reviews', runUpload, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await generateAiBuyerShowReviews({
|
||||
productName: body.productName || body.spu,
|
||||
spu: body.spu || body.productName,
|
||||
scene: body.scene,
|
||||
userType: body.userType,
|
||||
sellingPoints: body.sellingPoints,
|
||||
useEmoji: body.useEmoji,
|
||||
reviewTotal: body.reviewTotal,
|
||||
productImageFiles: req.files?.productImages || [],
|
||||
}, req.user || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] generate reviews failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '攻心评价生成失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/jobs', runUpload, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await createAiBuyerShowJob({
|
||||
productName: body.productName || body.spu,
|
||||
spu: body.spu || body.productName,
|
||||
scene: body.scene,
|
||||
userType: body.userType,
|
||||
sellingPoints: body.sellingPoints,
|
||||
reviewText: body.reviewText,
|
||||
generationId: body.generationId,
|
||||
reviewRecordId: body.reviewRecordId,
|
||||
prompt: body.prompt,
|
||||
negativePrompt: body.negativePrompt,
|
||||
buyerShowStyle: body.buyerShowStyle,
|
||||
imageCount: 5,
|
||||
aspectRatio: body.aspectRatio,
|
||||
imageSize: body.imageSize,
|
||||
productImageUrls: normalizeList(body.productImageUrls),
|
||||
sceneReferenceImageUrls: normalizeList(body.sceneReferenceImageUrls),
|
||||
productImageFiles: req.files?.productImages || [],
|
||||
sceneReferenceImageFiles: req.files?.sceneReferenceImages || [],
|
||||
}, req.user || {});
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] create job failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀任务创建失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/jobs', async (req, res) => {
|
||||
try {
|
||||
const result = await listAiBuyerShowJobs({
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
spu: req.query.spu,
|
||||
includeTotal: req.query.includeTotal !== '0',
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] list jobs failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀任务列表查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const data = await listAiBuyerShowHistory({
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
});
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] list history failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '历史生成记录加载失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/history/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await getAiBuyerShowHistoryDetail(req.params.id);
|
||||
if (!data) {
|
||||
res.status(404).json({ success: false, error: '历史生成记录不存在' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] get history failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '历史生成详情加载失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/image-proxy', async (req, res) => {
|
||||
const imageUrl = String(req.query.url || '').trim();
|
||||
if (!isAllowedImageUrl(imageUrl)) {
|
||||
res.status(400).json({ success: false, error: '不支持的图片地址' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: 'stream',
|
||||
timeout: 120000,
|
||||
maxContentLength: 30 * 1024 * 1024,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
});
|
||||
res.set('Content-Type', response.headers['content-type'] || 'application/octet-stream');
|
||||
res.set('Cache-Control', 'private, max-age=300');
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
res.status(error.response?.status || 502).json({
|
||||
success: false,
|
||||
error: error.message || '图片下载失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/jobs/:jobId', async (req, res) => {
|
||||
try {
|
||||
const job = await getAiBuyerShowJob(req.params.jobId);
|
||||
if (!job) {
|
||||
res.status(404).json({ success: false, error: '任务不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] get job failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀任务查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/queue-stats', async (req, res) => {
|
||||
try {
|
||||
const data = await getQueueStats(req.query.jobId || '');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀队列统计查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/seed-packs', runSeedPackUpload, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const data = await createSeedPack({
|
||||
shopName: body.shopName,
|
||||
productId: body.productId,
|
||||
skuName: body.skuName,
|
||||
fileName: body.fileName || req.file?.originalname,
|
||||
reviewCount: body.reviewCount,
|
||||
file: req.file,
|
||||
}, req.user || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] create seed pack failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包上传失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/seed-packs', async (req, res) => {
|
||||
try {
|
||||
const data = await listSeedPacks({
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
});
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] list seed packs failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包列表查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/seed-packs/:id/download', async (req, res) => {
|
||||
try {
|
||||
const data = await getSeedPackDownload(req.params.id);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] get seed pack download failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包下载信息获取失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/seed-packs/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await deleteSeedPack(req.params.id);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] delete seed pack failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包删除失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
93
backend/src/api/csQuality.js
Normal file
93
backend/src/api/csQuality.js
Normal file
@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getGoods,
|
||||
getChatDataMeta,
|
||||
getCustomers,
|
||||
getMessages,
|
||||
getProductProfile,
|
||||
startAnalysisJob,
|
||||
getAnalysisJob,
|
||||
runAnalysis,
|
||||
listHistory,
|
||||
getHistoryDetail,
|
||||
} = require('../services/csQualityService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '客服质检请求失败') {
|
||||
const status = Number(error?.statusCode) || (error?.message?.includes('不能超过30天') ? 400 : 500);
|
||||
console.error('[CsQuality]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
router.get('/goods', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getGoods(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载商品聊天列表失败'); }
|
||||
});
|
||||
|
||||
router.get('/data-meta', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getChatDataMeta() });
|
||||
} catch (error) { sendError(res, error, '加载聊天数据日期范围失败'); }
|
||||
});
|
||||
|
||||
router.get('/goods/:goodsId/profile', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getProductProfile({ ...req.query, goodsId: req.params.goodsId }) });
|
||||
} catch (error) { sendError(res, error, '加载商品表现失败'); }
|
||||
});
|
||||
|
||||
router.get('/customers', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getCustomers(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载客户列表失败'); }
|
||||
});
|
||||
|
||||
router.get('/messages', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMessages(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载聊天内容失败'); }
|
||||
});
|
||||
|
||||
router.post('/analysis', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await runAnalysis(req.body || {}, req.user || {}) });
|
||||
} catch (error) { sendError(res, error, 'AI质检失败'); }
|
||||
});
|
||||
|
||||
router.post('/analysis-jobs', async (req, res) => {
|
||||
try {
|
||||
const data = await startAnalysisJob(req.body || {}, req.user || {});
|
||||
res.status(202).json({ success: true, data });
|
||||
} catch (error) { sendError(res, error, '创建AI质检任务失败'); }
|
||||
});
|
||||
|
||||
router.get('/analysis-jobs/:analysisId', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getAnalysisJob(req.params.analysisId) });
|
||||
} catch (error) { sendError(res, error, '查询AI质检进度失败'); }
|
||||
});
|
||||
|
||||
router.get('/analysis-history', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await listHistory(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载AI质检历史失败'); }
|
||||
});
|
||||
|
||||
router.get('/analysis-history/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await getHistoryDetail(req.params.id);
|
||||
if (!data) return res.status(404).json({ success: false, error: '历史质检记录不存在' });
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) { return sendError(res, error, '加载AI质检历史详情失败'); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,364 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 });
|
||||
const DASHBOARD_CACHE_TTL_MS = 60 * 1000;
|
||||
const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000);
|
||||
const dashboardCache = new Map();
|
||||
const dashboardInFlight = new Map();
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function toDateString(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(value, offset) {
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function inclusiveDayCount(startDate, endDate) {
|
||||
const start = new Date(`${startDate}T00:00:00Z`);
|
||||
const end = new Date(`${endDate}T00:00:00Z`);
|
||||
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
}
|
||||
|
||||
function percentChange(current, previous) {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null;
|
||||
return ((current - previous) * 100) / Math.abs(previous);
|
||||
}
|
||||
|
||||
function sumRows(rows) {
|
||||
return rows.reduce((result, row) => {
|
||||
result.payAmount += toNumber(row.payAmount);
|
||||
result.buyerCount += toNumber(row.buyerCount);
|
||||
result.visitorCount += toNumber(row.visitorCount);
|
||||
result.goodsPv += toNumber(row.goodsPv);
|
||||
result.payGoodsQty += toNumber(row.payGoodsQty);
|
||||
result.payOrderCount += toNumber(row.payOrderCount);
|
||||
result.favoriteCount += toNumber(row.favoriteCount);
|
||||
result.impressionCount += toNumber(row.impressionCount);
|
||||
return result;
|
||||
}, {
|
||||
payAmount: 0,
|
||||
buyerCount: 0,
|
||||
visitorCount: 0,
|
||||
goodsPv: 0,
|
||||
payGoodsQty: 0,
|
||||
payOrderCount: 0,
|
||||
favoriteCount: 0,
|
||||
impressionCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function runDashboardQuery(name, sqlText, params = {}) {
|
||||
try {
|
||||
return {
|
||||
name,
|
||||
rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[dashboard] ${name} query failed:`, error.message);
|
||||
return { name, rows: [], error };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDashboardMetrics(current, previous) {
|
||||
const conversionRate = current.visitorCount > 0
|
||||
? (current.buyerCount * 100) / current.visitorCount
|
||||
: null;
|
||||
const previousConversionRate = previous.visitorCount > 0
|
||||
? (previous.buyerCount * 100) / previous.visitorCount
|
||||
: null;
|
||||
return [
|
||||
{ key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' },
|
||||
{ key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' },
|
||||
{ key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' },
|
||||
{ key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' },
|
||||
{ key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' },
|
||||
{ key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' },
|
||||
{ key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' },
|
||||
{ key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' },
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyDashboardMetrics() {
|
||||
return [
|
||||
{ key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' },
|
||||
{ key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' },
|
||||
{ key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' },
|
||||
];
|
||||
}
|
||||
|
||||
router.get('/meta', async (_req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT shopName
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
ORDER BY shopName`,
|
||||
{},
|
||||
{ requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }
|
||||
);
|
||||
res.json({ success: true, data: { platform: 'pdd', shops: rows.map((row) => row.shopName) } });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
let settleDashboard = null;
|
||||
let activeCacheKey = '';
|
||||
try {
|
||||
const requestedStartDate = normalizeDate(req.query.startDate);
|
||||
const requestedEndDate = normalizeDate(req.query.endDate);
|
||||
const hasCustomDate = Boolean(requestedStartDate || requestedEndDate);
|
||||
if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) {
|
||||
return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' });
|
||||
}
|
||||
const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0;
|
||||
if (hasCustomDate && customDays < 1) {
|
||||
return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' });
|
||||
}
|
||||
if (hasCustomDate && customDays > 90) {
|
||||
return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' });
|
||||
}
|
||||
const requestedPeriod = hasCustomDate
|
||||
? 'custom'
|
||||
: DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week';
|
||||
const shopName = String(req.query.shopName || '').trim();
|
||||
const cacheKey = JSON.stringify({
|
||||
period: requestedPeriod,
|
||||
startDate: requestedStartDate,
|
||||
endDate: requestedEndDate,
|
||||
shopName,
|
||||
});
|
||||
activeCacheKey = cacheKey;
|
||||
const cached = dashboardCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
const pending = dashboardInFlight.get(cacheKey);
|
||||
if (pending) {
|
||||
const result = await pending;
|
||||
if (result.error) throw result.error;
|
||||
return res.json({ success: true, data: result.data });
|
||||
}
|
||||
|
||||
dashboardInFlight.set(cacheKey, new Promise((resolve) => {
|
||||
settleDashboard = resolve;
|
||||
}));
|
||||
|
||||
const period = requestedPeriod;
|
||||
const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period];
|
||||
const [salesResult, serviceResult, starResult] = await Promise.all([
|
||||
runDashboardQuery(
|
||||
'sales',
|
||||
`WITH source_bounds AS (
|
||||
SELECT TOP (1) LEFT(createTime, 10) AS maxDate
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE createTime >= '2000-01-01'
|
||||
AND createTime < '2100-01-01'
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
ORDER BY createTime DESC
|
||||
),
|
||||
daily_coverage AS (
|
||||
SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount
|
||||
FROM dbo.pdd_DataGoods g
|
||||
CROSS JOIN source_bounds b
|
||||
WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23)
|
||||
AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23)
|
||||
AND g.shopName IS NOT NULL
|
||||
AND LTRIM(RTRIM(g.shopName)) <> ''
|
||||
AND (@shopName = '' OR g.shopName = @shopName)
|
||||
GROUP BY LEFT(g.createTime, 10)
|
||||
),
|
||||
coverage_target AS (
|
||||
SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage
|
||||
),
|
||||
available_end AS (
|
||||
SELECT MAX(c.statDate) AS latestDate
|
||||
FROM daily_coverage c
|
||||
CROSS JOIN coverage_target t
|
||||
WHERE c.shopCount >= t.maxShopCount * 0.8
|
||||
),
|
||||
selected_end AS (
|
||||
SELECT CASE
|
||||
WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate
|
||||
ELSE latestDate
|
||||
END AS latestDate
|
||||
FROM available_end
|
||||
)
|
||||
SELECT
|
||||
LEFT(g.createTime, 10) AS statDate,
|
||||
e.latestDate AS latestDate,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount
|
||||
FROM dbo.pdd_DataGoods g
|
||||
CROSS JOIN selected_end e
|
||||
WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23)
|
||||
AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23)
|
||||
AND (@shopName = '' OR g.shopName = @shopName)
|
||||
GROUP BY LEFT(g.createTime, 10), e.latestDate
|
||||
ORDER BY LEFT(g.createTime, 10)`,
|
||||
{ historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName }
|
||||
),
|
||||
runDashboardQuery(
|
||||
'service-score',
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber
|
||||
FROM dbo.pdd_MallServiceScore
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
AND createTime LIKE '[12][0-9][0-9][0-9]-%'
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
)
|
||||
SELECT
|
||||
MAX(LEFT(createTime, 10)) AS createTime,
|
||||
COUNT(*) AS shopCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1`,
|
||||
{ shopName }
|
||||
),
|
||||
runDashboardQuery(
|
||||
'shop-stars',
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber
|
||||
FROM dbo.pdd_ShopStars
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
AND createTime LIKE '[12][0-9][0-9][0-9]-%'
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
)
|
||||
SELECT
|
||||
MAX(LEFT(createTime, 10)) AS createTime,
|
||||
COUNT(*) AS shopCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1`,
|
||||
{ shopName }
|
||||
),
|
||||
]);
|
||||
const salesRows = salesResult.rows;
|
||||
const serviceRows = serviceResult.rows;
|
||||
const starRows = starResult.rows;
|
||||
const warnings = [salesResult, serviceResult, starResult]
|
||||
.filter((result) => result.error)
|
||||
.map((result) => result.name);
|
||||
const normalizedSales = salesRows
|
||||
.map((row) => ({
|
||||
date: String(row.statDate || ''),
|
||||
payAmount: toNumber(row.payAmount),
|
||||
buyerCount: toNumber(row.buyerCount),
|
||||
visitorCount: toNumber(row.visitorCount),
|
||||
goodsPv: toNumber(row.goodsPv),
|
||||
payGoodsQty: toNumber(row.payGoodsQty),
|
||||
payOrderCount: toNumber(row.payOrderCount),
|
||||
favoriteCount: toNumber(row.favoriteCount),
|
||||
impressionCount: toNumber(row.impressionCount),
|
||||
}))
|
||||
.filter((row) => normalizeDate(row.date));
|
||||
const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || '');
|
||||
const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : '';
|
||||
const previousEnd = currentStart ? addDays(currentStart, -1) : '';
|
||||
const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : '';
|
||||
const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate);
|
||||
const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd);
|
||||
const current = sumRows(currentRows);
|
||||
const previous = sumRows(previousRows);
|
||||
const metrics = latestDate && !salesResult.error
|
||||
? buildDashboardMetrics(current, previous)
|
||||
: buildEmptyDashboardMetrics();
|
||||
|
||||
const spend = null;
|
||||
const gmv = null;
|
||||
const roi = null;
|
||||
|
||||
const responseData = {
|
||||
platform: 'pdd',
|
||||
source: 'jiansun_database',
|
||||
scope: shopName ? 'single-shop' : 'all-shops',
|
||||
shopName: shopName || '全部店铺',
|
||||
warnings,
|
||||
period,
|
||||
days,
|
||||
latestDate,
|
||||
startDate: currentStart,
|
||||
previousStart,
|
||||
previousEnd,
|
||||
metrics,
|
||||
trend: currentRows,
|
||||
promotion: {
|
||||
spend,
|
||||
gmv,
|
||||
roi,
|
||||
clickCount: null,
|
||||
orderCount: null,
|
||||
},
|
||||
refunds: {
|
||||
amount: null,
|
||||
rate: null,
|
||||
products: [],
|
||||
},
|
||||
store: {
|
||||
serviceDate: toDateString(serviceRows[0]?.createTime),
|
||||
starDate: toDateString(starRows[0]?.createTime),
|
||||
shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)),
|
||||
star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms),
|
||||
peerDescription: shopName
|
||||
? shopName
|
||||
: `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`,
|
||||
activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk),
|
||||
scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk),
|
||||
scores: [
|
||||
{ key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) },
|
||||
{ key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) },
|
||||
{ key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) },
|
||||
{ key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) },
|
||||
],
|
||||
},
|
||||
};
|
||||
dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS });
|
||||
settleDashboard({ data: responseData });
|
||||
dashboardInFlight.delete(cacheKey);
|
||||
res.json({ success: true, data: responseData });
|
||||
} catch (error) {
|
||||
settleDashboard?.({ error });
|
||||
if (activeCacheKey) dashboardInFlight.delete(activeCacheKey);
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
@ -1,6 +1,18 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
const {
|
||||
buildPublicUrl,
|
||||
getDefaultS3Config,
|
||||
getObjectFromDefaultS3,
|
||||
} = require('../services/objectStorage');
|
||||
const {
|
||||
compareVersions,
|
||||
getDesktopReleaseById,
|
||||
getLatestDesktopRelease,
|
||||
mapRelease,
|
||||
normalizeVersion,
|
||||
} = require('../services/desktopReleaseService');
|
||||
const {
|
||||
DesktopSessionError,
|
||||
createDesktopSession,
|
||||
@ -12,6 +24,23 @@ const {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function publicRelease(release) {
|
||||
const mapped = mapRelease(release);
|
||||
if (!mapped) return null;
|
||||
return {
|
||||
id: mapped.id,
|
||||
version: mapped.version,
|
||||
releaseNotes: mapped.releaseNotes,
|
||||
platform: mapped.platform,
|
||||
architecture: mapped.architecture,
|
||||
fileName: mapped.fileName,
|
||||
fileSize: mapped.fileSize,
|
||||
sha256: mapped.sha256,
|
||||
publishedAt: mapped.publishedAt,
|
||||
downloadUrl: `/api/desktop-client/releases/${mapped.id}/download`,
|
||||
};
|
||||
}
|
||||
|
||||
function requestUserId(req) {
|
||||
return req.user?.sub ?? req.user?.id;
|
||||
}
|
||||
@ -135,4 +164,68 @@ router.post('/session/revoke', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/latest', async (req, res) => {
|
||||
try {
|
||||
const platform = req.query.platform || 'win32';
|
||||
const architecture = req.query.architecture || '';
|
||||
const release = await getLatestDesktopRelease(platform, architecture);
|
||||
const currentVersion = normalizeVersion(req.query.currentVersion);
|
||||
const data = publicRelease(release);
|
||||
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
||||
res.json({
|
||||
success: true,
|
||||
data: data ? {
|
||||
...data,
|
||||
currentVersion: currentVersion || null,
|
||||
updateAvailable: currentVersion ? compareVersions(data.version, currentVersion) > 0 : null,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取桌面客户端最新版本失败:', error);
|
||||
res.status(500).json({ success: false, error: '获取桌面客户端最新版本失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/releases/:id/download', async (req, res) => {
|
||||
try {
|
||||
const release = await getDesktopReleaseById(Number(req.params.id));
|
||||
if (!release) {
|
||||
return res.status(404).json({ success: false, error: '安装包不存在或已下线' });
|
||||
}
|
||||
|
||||
const storageConfig = await getDefaultS3Config();
|
||||
if (storageConfig?.customDomain) {
|
||||
return res.redirect(302, buildPublicUrl(storageConfig, release.objectKey));
|
||||
}
|
||||
|
||||
const object = await getObjectFromDefaultS3(release.objectKey, {
|
||||
range: req.headers.range,
|
||||
});
|
||||
const encodedName = encodeURIComponent(release.fileName).replace(/%20/g, '+');
|
||||
res.status(object.ContentRange ? 206 : 200);
|
||||
const fallbackContentType = release.platform === 'darwin'
|
||||
? 'application/x-apple-diskimage'
|
||||
: 'application/vnd.microsoft.portable-executable';
|
||||
res.setHeader('Content-Type', object.ContentType || fallbackContentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`);
|
||||
res.setHeader('Accept-Ranges', object.AcceptRanges || 'bytes');
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
if (object.ContentLength != null) res.setHeader('Content-Length', String(object.ContentLength));
|
||||
if (object.ContentRange) res.setHeader('Content-Range', object.ContentRange);
|
||||
if (object.ETag) res.setHeader('ETag', object.ETag);
|
||||
|
||||
object.Body.on('error', (error) => {
|
||||
console.error('桌面客户端安装包下载流中断:', error);
|
||||
if (!res.headersSent) res.status(502).end();
|
||||
else res.destroy(error);
|
||||
});
|
||||
object.Body.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('下载桌面客户端安装包失败:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ success: false, error: '安装包下载失败,请稍后重试' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -5,11 +5,6 @@ const {
|
||||
shouldAutoRestartAfterInstall,
|
||||
testSystemDatabaseBootstrapConnection,
|
||||
} = require('../services/installService');
|
||||
const {
|
||||
DEFAULT_BOOTSTRAP_ENDPOINT,
|
||||
enrollRemoteInstallation,
|
||||
scheduleRemoteRestart,
|
||||
} = require('../services/remoteEnrollmentService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -23,47 +18,6 @@ router.get('/status', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/remote-bootstrap', (_req, res) => {
|
||||
res.json({
|
||||
enabled: true,
|
||||
defaultEndpoint: DEFAULT_BOOTSTRAP_ENDPOINT,
|
||||
supervised: process.env.CANGKU_REMOTE_SUPERVISOR === 'true',
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/remote-bootstrap', async (req, res) => {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
const status = await getInstallStatus();
|
||||
if (!status.required) {
|
||||
return res.status(409).json({ error: '系统数据库已经配置,不能重复注册远程部署' });
|
||||
}
|
||||
|
||||
const result = await enrollRemoteInstallation(req.body || {});
|
||||
const restartScheduled = process.env.CANGKU_REMOTE_SUPERVISOR === 'true';
|
||||
res.once('finish', () => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
scheduleRemoteRestart();
|
||||
} catch (error) {
|
||||
console.error('写入远程启动标记失败:', error.message);
|
||||
}
|
||||
}, 800);
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
message: restartScheduled
|
||||
? '远程系统数据库配置完成,服务正在自动重启'
|
||||
: '远程系统数据库配置完成,请使用 npm run remote:dev 重新启动',
|
||||
restartScheduled,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('远程部署注册失败:', error.message);
|
||||
return res.status(400).json({ error: error.message || '远程部署注册失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/system-database/test', async (req, res) => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
268
backend/src/api/marketingEvents.js
Normal file
268
backend/src/api/marketingEvents.js
Normal file
@ -0,0 +1,268 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeSortField(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price',
|
||||
'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
return allowed.has(field) ? field : 'activity_goods_start_time';
|
||||
}
|
||||
|
||||
function normalizeSortOrder(order) {
|
||||
return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
}
|
||||
|
||||
function escapeColumn(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price',
|
||||
'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
if (!allowed.has(field)) return '[id]';
|
||||
return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`;
|
||||
}
|
||||
|
||||
async function getSecondLatestDate() {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
if (rows.length >= 2) return rows[1].createDate;
|
||||
if (rows.length === 1) return rows[0].createDate;
|
||||
return '';
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const date = await getSecondLatestDate();
|
||||
res.json({ success: true, data: { date } });
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'activity_goods_start_time',
|
||||
sortOrder = 'DESC',
|
||||
shopName,
|
||||
activityType,
|
||||
activityName,
|
||||
activityId,
|
||||
goodsName,
|
||||
goodsId,
|
||||
status,
|
||||
date,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const sortColumn = normalizeSortField(sortBy);
|
||||
const sortDir = normalizeSortOrder(sortOrder);
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] LIKE @shopName');
|
||||
params.shopName = `%${shopName.trim()}%`;
|
||||
}
|
||||
if (activityType && activityType.trim()) {
|
||||
whereParts.push('[business_type_str] LIKE @activityType');
|
||||
params.activityType = `%${activityType.trim()}%`;
|
||||
}
|
||||
if (activityName && activityName.trim()) {
|
||||
whereParts.push('[activity_name] LIKE @activityName');
|
||||
params.activityName = `%${activityName.trim()}%`;
|
||||
}
|
||||
if (activityId && String(activityId).trim()) {
|
||||
whereParts.push('[activity_id] = @activityId');
|
||||
params.activityId = Number(activityId);
|
||||
}
|
||||
if (goodsName && goodsName.trim()) {
|
||||
whereParts.push('[goods_name] LIKE @goodsName');
|
||||
params.goodsName = `%${goodsName.trim()}%`;
|
||||
}
|
||||
if (goodsId && String(goodsId).trim()) {
|
||||
whereParts.push('[goods_id] = @goodsId');
|
||||
params.goodsId = String(goodsId).trim();
|
||||
}
|
||||
if (status !== undefined && status !== '' && status !== 'all') {
|
||||
whereParts.push('[status] = @status');
|
||||
params.status = Number(status);
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[business_type_str] AS activityType,
|
||||
[activity_name] AS activityName,
|
||||
[activity_id] AS activityId,
|
||||
[goods_name] AS goodsName,
|
||||
[goods_id] AS goodsId,
|
||||
[hd_thumb_url] AS thumbUrl,
|
||||
[enroll_time] AS enrollTime,
|
||||
TRY_CONVERT(decimal(18,2), [min_activity_sku_price]) / 100.0 AS minPrice,
|
||||
TRY_CONVERT(decimal(18,2), [max_activity_sku_price]) / 100.0 AS maxPrice,
|
||||
[activity_goods_start_time] AS activityStartTime,
|
||||
[activity_goods_end_time] AS activityEndTime,
|
||||
[status],
|
||||
CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
ORDER BY ${escapeColumn(sortColumn)} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const statusCountsResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`,
|
||||
params
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let totalAll = 0;
|
||||
statusCountsResult.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
totalAll += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: dataResult,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
filterDate,
|
||||
statusCounts: { all: totalAll, ...statusCounts },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询活动记录失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date } = req.query;
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
|
||||
const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : '';
|
||||
const params = filterDate ? { filterDate } : {};
|
||||
|
||||
const [row] = await jianSunDb.query(
|
||||
`SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount,
|
||||
COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount,
|
||||
COUNT(DISTINCT [shopName]) AS shopCount,
|
||||
COUNT(DISTINCT [activity_id]) AS activityIdCount,
|
||||
COUNT(DISTINCT [business_type_str]) AS activityTypeCount,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice,
|
||||
MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice,
|
||||
MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}`,
|
||||
params
|
||||
);
|
||||
|
||||
const stats = row || {};
|
||||
const totalRecords = Number(stats.totalRecords) || 0;
|
||||
const successCount = Number(stats.successCount) || 0;
|
||||
const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate,
|
||||
totalRecords,
|
||||
successCount,
|
||||
successRate,
|
||||
goodsCount: Number(stats.goodsCount) || 0,
|
||||
shopCount: Number(stats.shopCount) || 0,
|
||||
activityIdCount: Number(stats.activityIdCount) || 0,
|
||||
activityTypeCount: Number(stats.activityTypeCount) || 0,
|
||||
avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-',
|
||||
avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-',
|
||||
globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-',
|
||||
globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status-counts', async (req, res) => {
|
||||
try {
|
||||
const result = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let total = 0;
|
||||
result.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
total += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { all: total, ...statusCounts },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询状态统计失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,9 +1,20 @@
|
||||
const express = require('express');
|
||||
const { getResolvedUserWithPageAccess } = require('../services/pageAccess');
|
||||
const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService');
|
||||
const { getAlibaba1688Product } = require('../services/alibaba1688ProductService');
|
||||
const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry');
|
||||
const router = express.Router();
|
||||
|
||||
async function ensurePlanningUploadAccess(req) {
|
||||
const user = await getResolvedUserWithPageAccess(req.user.sub);
|
||||
if (!user || (user.role !== 'admin' && !user.allowedPageKeys?.includes('ops_b_incentive'))) {
|
||||
const error = new Error('当前账号没有激励看板权限');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function ensureSelectionFissionAccess(req) {
|
||||
// Selection workflows are available to every authenticated user. They share
|
||||
// this router with template uploads, but must not inherit upload permissions.
|
||||
@ -29,4 +40,43 @@ router.get('/selection-fission/1688-products/:productId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/batches', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const data = await getOpsBIncentiveBatches();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B激励看板批次列表失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B激励看板批次列表失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveBoard(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励看板失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励看板失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/daily', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveDaily(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励每日明细失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励每日明细失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
833
backend/src/api/pricingCalculator.js
Normal file
833
backend/src/api/pricingCalculator.js
Normal file
@ -0,0 +1,833 @@
|
||||
const express = require('express');
|
||||
const XLSX = require('xlsx');
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getCompanySkusBySpu,
|
||||
fetchFreightProfile,
|
||||
getSelectionFissionProfitPricingContext,
|
||||
getSelectionFissionProductCodeMeta,
|
||||
saveSelectionFissionProfitPricingTable,
|
||||
} = require('../services/planningSelectionFissionService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const HISTORY_TABLE = 'pricing_calculator_history';
|
||||
// 运费/售后画像查询设定超时,避免整页等待。
|
||||
const FREIGHT_PROFILE_TIMEOUT_MS = Math.max(
|
||||
Number.parseInt(process.env.PRICING_CALC_FREIGHT_TIMEOUT_MS || '30000', 10) || 30000,
|
||||
5000
|
||||
);
|
||||
const MAX_HISTORY_ROWS = 500;
|
||||
const MAX_PAYLOAD_BYTES = 1024 * 1024;
|
||||
const CALCULATOR_TYPES = new Set(['company', 'alibaba1688']);
|
||||
|
||||
let historyTableReady = null;
|
||||
|
||||
async function ensureHistoryTable() {
|
||||
if (!historyTableReady) {
|
||||
historyTableReady = (async () => {
|
||||
// 先查表是否存在:生产/隧道账号通常只有 SELECT/INSERT/UPDATE/DELETE,
|
||||
// MariaDB 对 CREATE TABLE IF NOT EXISTS 也会校验 CREATE 权限,存在时直接跳过。
|
||||
const existing = await database.local.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[HISTORY_TABLE]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return;
|
||||
}
|
||||
await database.local.query(
|
||||
`CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
|
||||
user_id BIGINT NOT NULL COMMENT '测算人用户ID',
|
||||
user_name VARCHAR(100) NULL COMMENT '测算人显示名',
|
||||
spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU',
|
||||
brand VARCHAR(100) NULL COMMENT '品牌名称',
|
||||
note VARCHAR(500) NULL COMMENT '备注',
|
||||
row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数',
|
||||
payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_pricing_calc_history_user_created (user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算历史记录'`
|
||||
);
|
||||
})();
|
||||
}
|
||||
try {
|
||||
await historyTableReady;
|
||||
} catch (error) {
|
||||
// 建表失败后允许下次请求重试
|
||||
historyTableReady = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function sendError(res, error, fallbackMessage) {
|
||||
const status = error.status || error.statusCode || 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error.message || fallbackMessage,
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestUser(req) {
|
||||
const user = req.user || {};
|
||||
return {
|
||||
id: Number(user.sub || user.id || 0) || null,
|
||||
name: user.name || user.username || '',
|
||||
isAdmin: user.role === 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
function requireUserId(req, res) {
|
||||
const { id } = getRequestUser(req);
|
||||
if (!id) {
|
||||
res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const normalizeText = (value, maxLength) => String(value ?? '').trim().slice(0, maxLength);
|
||||
|
||||
const normalizeCalculatorType = (value) => (
|
||||
CALCULATOR_TYPES.has(value) ? value : 'company'
|
||||
);
|
||||
|
||||
function sanitizeHistoryProduct(input) {
|
||||
const product = input && typeof input === 'object' ? input : {};
|
||||
const dimensions = (Array.isArray(product.skuDimensions) ? product.skuDimensions : [])
|
||||
.slice(0, 4)
|
||||
.map((dimension) => ({
|
||||
name: normalizeText(dimension?.name, 120),
|
||||
values: (Array.isArray(dimension?.values) ? dimension.values : [])
|
||||
.slice(0, 500)
|
||||
.map((value) => normalizeText(value, 500))
|
||||
.filter(Boolean),
|
||||
}))
|
||||
.filter((dimension) => dimension.name);
|
||||
return {
|
||||
productId: normalizeText(product.productId, 80),
|
||||
title: normalizeText(product.title, 500),
|
||||
mainImage: normalizeText(product.mainImage, 2000),
|
||||
sourceUrl: normalizeText(product.sourceUrl, 2000),
|
||||
storeName: normalizeText(product.storeName, 300),
|
||||
category: normalizeText(product.category, 500),
|
||||
skuDimensions: dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSkuRow(row) {
|
||||
const qgyf = Number(row?.raw?.qgyf);
|
||||
return {
|
||||
skuCode: row.skuCode || '',
|
||||
status: row.saleStatus || '',
|
||||
stock: row.stock ?? null,
|
||||
stockWarehouses: Array.isArray(row.stockWarehouses) ? row.stockWarehouses : [],
|
||||
name: row.skuName || row.productName || '',
|
||||
image: row.skuImageUrl || '',
|
||||
size: row.size || '',
|
||||
color: row.colorName || '',
|
||||
cost: row.cost ?? null,
|
||||
minimumPrice: row.minimumPrice ?? null,
|
||||
salePrice: row?.raw?.sale_price ?? row?.raw?.salePrice ?? null,
|
||||
// qgyf = 聚水潭接口返回的全国平均运费(按条码)
|
||||
freight: Number.isFinite(qgyf) ? qgyf : null,
|
||||
supplier: row.supplierName || '',
|
||||
category: row.categoryName || '',
|
||||
lengthCm: row.lengthCm ?? row?.raw?.length_cm ?? row?.raw?.length ?? null,
|
||||
widthCm: row.widthCm ?? row?.raw?.width_cm ?? row?.raw?.width ?? null,
|
||||
heightCm: row.heightCm ?? row?.raw?.height_cm ?? row?.raw?.height ?? null,
|
||||
weight: row.weight ?? row?.raw?.weight ?? row?.raw?.gross_weight ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFreightProfileBestEffort(spuCode, skuRows, user) {
|
||||
const emptyProfile = {
|
||||
nationalFreight: null,
|
||||
zone1Freight: null,
|
||||
zone2Freight: null,
|
||||
zone3Freight: null,
|
||||
zone4Freight: null,
|
||||
aftersaleRatio: null,
|
||||
minimumPricesBySku: {},
|
||||
raw: null,
|
||||
};
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
fetchFreightProfile({ spuCode, skuRows }, user),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ ...emptyProfile, warning: '运费/售后信息查询超时,可手动填写运费' }), FREIGHT_PROFILE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return result && typeof result === 'object' ? { ...emptyProfile, ...result } : emptyProfile;
|
||||
} catch (error) {
|
||||
return { ...emptyProfile, warning: error.message || '运费/售后信息查询失败' };
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取 SPU 下全部条码(含销售、退市、停产)+ 运费/售后画像(尽力而为)
|
||||
// 运费由聚水潭接口 qgyf 字段按条码直出,
|
||||
// 默认跳过;如需售后占比/最低限价画像,可设 PRICING_CALC_FREIGHT_PROFILE=on 重新开启。
|
||||
const FREIGHT_PROFILE_ENABLED = String(process.env.PRICING_CALC_FREIGHT_PROFILE || 'off').toLowerCase() === 'on';
|
||||
|
||||
function getSelectionSkuCodeSet(selectionContext) {
|
||||
if (!selectionContext) return null;
|
||||
const selectedSkuCodes = Array.isArray(selectionContext.skuSceneImages?.selectedSkuCodes)
|
||||
? selectionContext.skuSceneImages.selectedSkuCodes
|
||||
: [];
|
||||
const fallbackSkuCodes = Array.isArray(selectionContext.rows)
|
||||
? selectionContext.rows.map((row) => row.skuCode)
|
||||
: [];
|
||||
return new Set(
|
||||
(selectedSkuCodes.length > 0 ? selectedSkuCodes : fallbackSkuCodes)
|
||||
.map((skuCode) => normalizeText(skuCode, 160).toUpperCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function filterSkuRowsForSelectionContext(skuRows, selectionContext) {
|
||||
const rows = Array.isArray(skuRows) ? skuRows : [];
|
||||
const selectedSkuCodes = getSelectionSkuCodeSet(selectionContext);
|
||||
if (!selectedSkuCodes) return rows;
|
||||
return rows.filter((row) => selectedSkuCodes.has(
|
||||
normalizeText(row?.skuCode || row?.sku_code || row?.barcode, 160).toUpperCase()
|
||||
));
|
||||
}
|
||||
|
||||
router.get('/product-code/meta', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
try {
|
||||
res.json({ success: true, data: await getSelectionFissionProductCodeMeta() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '公司SKU编码配置加载失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/spu/:spuCode', async (req, res) => {
|
||||
const spuCode = normalizeText(req.params.spuCode, 160).toUpperCase();
|
||||
if (!spuCode) {
|
||||
return res.status(400).json({ success: false, error: '请输入公司 SPU' });
|
||||
}
|
||||
|
||||
try {
|
||||
const planId = Number.parseInt(req.query.planId, 10) || 0;
|
||||
const itemId = Number.parseInt(req.query.itemId, 10) || 0;
|
||||
if (Boolean(planId) !== Boolean(itemId)) {
|
||||
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
|
||||
}
|
||||
const skuResult = await getCompanySkusBySpu(spuCode, { includeInactive: true });
|
||||
const allSkuRows = Array.from(skuResult.rows || []);
|
||||
if (allSkuRows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `未查询到 SPU「${spuCode}」的条码,请确认 SPU 是否正确`,
|
||||
});
|
||||
}
|
||||
|
||||
let selectionContext = null;
|
||||
if (planId && itemId) {
|
||||
selectionContext = await getSelectionFissionProfitPricingContext(planId, itemId);
|
||||
if (selectionContext.spuCode.toUpperCase() !== spuCode) {
|
||||
return res.status(409).json({ success: false, error: `该选品任务绑定的是SPU ${selectionContext.spuCode}` });
|
||||
}
|
||||
}
|
||||
const skuRows = filterSkuRowsForSelectionContext(allSkuRows, selectionContext);
|
||||
if (selectionContext && skuRows.length === 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: '选品任务已选 SKU 不在当前 SPU 条码列表中,请返回 SKU 节点重新确认',
|
||||
});
|
||||
}
|
||||
|
||||
const freightProfile = FREIGHT_PROFILE_ENABLED
|
||||
? await fetchFreightProfileBestEffort(spuCode, skuRows, req.user)
|
||||
: { nationalFreight: null, aftersaleRatio: null, minimumPricesBySku: {}, warning: null };
|
||||
const warnings = [...(skuResult.warnings || [])];
|
||||
if (freightProfile.warning) {
|
||||
warnings.push(freightProfile.warning);
|
||||
}
|
||||
|
||||
const savedRowsBySku = new Map((selectionContext?.rows || []).map((row) => [
|
||||
String(row.skuCode || '').toUpperCase(),
|
||||
row,
|
||||
]));
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
spu: spuCode,
|
||||
source: skuResult.source,
|
||||
warnings,
|
||||
freightProfile: {
|
||||
nationalFreight: freightProfile.nationalFreight ?? null,
|
||||
zone1Freight: freightProfile.zone1Freight ?? null,
|
||||
zone2Freight: freightProfile.zone2Freight ?? null,
|
||||
zone3Freight: freightProfile.zone3Freight ?? null,
|
||||
zone4Freight: freightProfile.zone4Freight ?? null,
|
||||
aftersaleRatio: freightProfile.aftersaleRatio ?? null,
|
||||
minimumPricesBySku: freightProfile.minimumPricesBySku || {},
|
||||
},
|
||||
selectionContext: selectionContext ? {
|
||||
planId: selectionContext.planId,
|
||||
itemId: selectionContext.itemId,
|
||||
taskCode: selectionContext.taskCode,
|
||||
schemeCode: selectionContext.schemeCode,
|
||||
listedItemId: selectionContext.listedItemId,
|
||||
pricingTableSavedAt: selectionContext.pricingTableSavedAt,
|
||||
pricingTableSavedBy: selectionContext.pricingTableSavedBy,
|
||||
pricingTableVersion: selectionContext.pricingTableVersion,
|
||||
workflowProfile: selectionContext.workflowProfile,
|
||||
productCodeStatus: selectionContext.productCodeStatus,
|
||||
productCodeStyleCode: selectionContext.productCodeStyleCode,
|
||||
productCodeConfig: selectionContext.productCodeConfig,
|
||||
productCodeErrorMessage: selectionContext.productCodeErrorMessage,
|
||||
productCodeRequestedAt: selectionContext.productCodeRequestedAt,
|
||||
productCodeCompletedAt: selectionContext.productCodeCompletedAt,
|
||||
} : null,
|
||||
rows: skuRows.map((skuRow) => {
|
||||
const mapped = mapSkuRow(skuRow);
|
||||
const saved = savedRowsBySku.get(String(mapped.skuCode || '').toUpperCase());
|
||||
return {
|
||||
...mapped,
|
||||
savedPricing: saved ? {
|
||||
price: saved.finalPrice,
|
||||
groupPrice: saved.groupPrice,
|
||||
installFee: saved.installationFee,
|
||||
coupon: saved.discountAmount,
|
||||
activityDiscount: saved.activityDiscount,
|
||||
shipping: saved.freightNational,
|
||||
commission: saved.platformCommissionRatio,
|
||||
promoRate: saved.promotionRatio,
|
||||
adFee: saved.marketingAdRatio,
|
||||
seeding: saved.farmingRatio,
|
||||
afterSale: saved.aftersaleRatio,
|
||||
insurance: saved.insuranceRatio,
|
||||
brandFee: saved.brandUsageRatio,
|
||||
targetMargin: saved.operatingProfitRatio,
|
||||
originalSkuName: saved.originalSkuName,
|
||||
selectedSkuName: saved.selectedSkuName,
|
||||
internalProductCode: saved.internalProductCode,
|
||||
productCodeProductName: saved.productCodeProductName,
|
||||
productCodeColorSpec: saved.productCodeColorSpec,
|
||||
} : null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, 'SPU 条码查询失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存一条测算历史(完整行快照,含输入与公式结果)
|
||||
router.post('/history', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const body = req.body || {};
|
||||
const spuCode = normalizeText(body.spuCode, 160).toUpperCase();
|
||||
const calculatorType = normalizeCalculatorType(body.calculatorType);
|
||||
const rows = Array.isArray(body.rows) ? body.rows : [];
|
||||
if (!spuCode) {
|
||||
return res.status(400).json({ success: false, error: '缺少 SPU 编码' });
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return res.status(400).json({ success: false, error: '测算行为空,无法保存' });
|
||||
}
|
||||
if (rows.length > MAX_HISTORY_ROWS) {
|
||||
return res.status(400).json({ success: false, error: `单条历史最多保存 ${MAX_HISTORY_ROWS} 行` });
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: 4,
|
||||
calculatorType,
|
||||
spuCode,
|
||||
isBlackLabelShop: body.isBlackLabelShop === true,
|
||||
brand: normalizeText(body.brand, 100),
|
||||
note: normalizeText(body.note, 500),
|
||||
savedAt: new Date().toISOString(),
|
||||
summary: body.summary && typeof body.summary === 'object' ? body.summary : null,
|
||||
freightProfile: body.freightProfile && typeof body.freightProfile === 'object' ? body.freightProfile : null,
|
||||
product: calculatorType === 'alibaba1688' ? sanitizeHistoryProduct(body.product) : null,
|
||||
rows,
|
||||
};
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
if (Buffer.byteLength(payloadJson, 'utf8') > MAX_PAYLOAD_BYTES) {
|
||||
return res.status(400).json({ success: false, error: '测算快照过大,无法保存' });
|
||||
}
|
||||
|
||||
try {
|
||||
let selectionPricing = null;
|
||||
const planId = Number.parseInt(body.selectionContext?.planId || body.planId, 10) || 0;
|
||||
const itemId = Number.parseInt(body.selectionContext?.itemId || body.itemId, 10) || 0;
|
||||
if (Boolean(planId) !== Boolean(itemId)) {
|
||||
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
|
||||
}
|
||||
if (planId && itemId) {
|
||||
// 测算页会展示实时接口中的全部条码;自动上架保存用户保留的任务 SKU 子集。
|
||||
const pricingContext = await getSelectionFissionProfitPricingContext(planId, itemId);
|
||||
const listingSkuCodes = new Set((pricingContext.rows || []).map((row) => (
|
||||
normalizeText(row.skuCode, 160).toUpperCase()
|
||||
)));
|
||||
const listingRows = rows.filter((row) => listingSkuCodes.has(
|
||||
normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase()
|
||||
));
|
||||
selectionPricing = await saveSelectionFissionProfitPricingTable(
|
||||
planId,
|
||||
itemId,
|
||||
{
|
||||
spuCode,
|
||||
rows: listingRows,
|
||||
note: payload.note,
|
||||
productCodeConfig: body.productCodeConfig,
|
||||
},
|
||||
req.user,
|
||||
{ pricingContext }
|
||||
);
|
||||
}
|
||||
let insertId = null;
|
||||
if (!selectionPricing) {
|
||||
await ensureHistoryTable();
|
||||
const inserted = await database.local.insert(HISTORY_TABLE, {
|
||||
user_id: userId,
|
||||
user_name: getRequestUser(req).name || null,
|
||||
spu_code: spuCode,
|
||||
brand: payload.brand || null,
|
||||
note: payload.note || null,
|
||||
row_count: rows.length,
|
||||
payload_json: payloadJson,
|
||||
});
|
||||
insertId = inserted.insertId;
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: insertId,
|
||||
spuCode,
|
||||
rowCount: rows.length,
|
||||
selectionPricing: selectionPricing ? {
|
||||
planId: selectionPricing.planId,
|
||||
itemId: selectionPricing.itemId,
|
||||
taskCode: selectionPricing.taskCode,
|
||||
pricingTableSavedAt: selectionPricing.pricingTableSavedAt,
|
||||
pricingTableVersion: selectionPricing.pricingTableVersion,
|
||||
productCodeStatus: selectionPricing.productCodeStatus,
|
||||
productCodeStyleCode: selectionPricing.productCodeStyleCode,
|
||||
productCodeConfig: selectionPricing.productCodeConfig,
|
||||
productCodeCompletedAt: selectionPricing.productCodeCompletedAt,
|
||||
productCodes: (selectionPricing.rows || []).map((row) => ({
|
||||
skuCode: row.skuCode,
|
||||
originalSkuName: row.originalSkuName,
|
||||
selectedSkuName: row.selectedSkuName,
|
||||
internalProductCode: row.internalProductCode,
|
||||
productCodeProductName: row.productCodeProductName,
|
||||
})),
|
||||
} : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存测算记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 历史列表(管理员可见全部,普通用户仅本人,不含快照正文)
|
||||
router.get('/history', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
const { isAdmin } = getRequestUser(req);
|
||||
|
||||
const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(Number.parseInt(req.query.pageSize, 10) || 20, 1), 100);
|
||||
const spuCode = normalizeText(req.query.spuCode, 160).toUpperCase();
|
||||
const calculatorType = normalizeText(req.query.calculatorType, 30);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
await ensureHistoryTable();
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (!isAdmin) {
|
||||
where.push('user_id = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
if (spuCode) {
|
||||
where.push('spu_code = ?');
|
||||
params.push(spuCode);
|
||||
}
|
||||
if (calculatorType === 'alibaba1688') {
|
||||
where.push('payload_json LIKE ?');
|
||||
params.push('%"calculatorType":"alibaba1688"%');
|
||||
} else if (calculatorType === 'company') {
|
||||
// 兼容 version 1-3 的公司品历史,它们还没有 calculatorType 字段。
|
||||
where.push('payload_json NOT LIKE ?');
|
||||
params.push('%"calculatorType":"alibaba1688"%');
|
||||
}
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const [countRows, list] = await Promise.all([
|
||||
database.local.query(`SELECT COUNT(*) AS total FROM ${HISTORY_TABLE} ${whereSql}`, params),
|
||||
database.local.query(
|
||||
`SELECT id, user_id, user_name, spu_code, brand, note, row_count, created_at, updated_at
|
||||
FROM ${HISTORY_TABLE}
|
||||
${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: list.map((row) => ({
|
||||
id: row.id,
|
||||
userName: row.user_name || '',
|
||||
spuCode: row.spu_code,
|
||||
brand: row.brand || '',
|
||||
note: row.note || '',
|
||||
rowCount: row.row_count,
|
||||
calculatorType: calculatorType === 'alibaba1688' ? 'alibaba1688' : 'company',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
})),
|
||||
total: Number(countRows[0]?.total || 0),
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '查询测算历史失败');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadOwnHistoryRow(req, res) {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return null;
|
||||
const { isAdmin } = getRequestUser(req);
|
||||
|
||||
const id = Number.parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
res.status(400).json({ success: false, error: '无效的历史记录 ID' });
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureHistoryTable();
|
||||
// 服务端强制数据边界:管理员可见全部,普通用户的他人记录表现为不存在
|
||||
const rows = isAdmin
|
||||
? await database.local.query(`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? LIMIT 1`, [id])
|
||||
: await database.local.query(
|
||||
`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
[id, userId]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ success: false, error: '测算记录不存在或已被删除' });
|
||||
return null;
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// 历史详情(仅本人)
|
||||
router.get('/history/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await loadOwnHistoryRow(req, res);
|
||||
if (!row) return;
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(row.payload_json);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: row.id,
|
||||
userName: row.user_name || '',
|
||||
spuCode: row.spu_code,
|
||||
brand: row.brand || '',
|
||||
note: row.note || '',
|
||||
rowCount: row.row_count,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '查询测算记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 删除历史(本人,或管理员可管理全部)
|
||||
router.delete('/history/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await loadOwnHistoryRow(req, res);
|
||||
if (!row) return;
|
||||
|
||||
await database.local.query(
|
||||
`DELETE FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ?`,
|
||||
[row.id, row.user_id]
|
||||
);
|
||||
res.json({ success: true, data: { id: row.id } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '删除测算记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 导出测算表:按前端给定的列顺序(与页面显示一致)与行数据生成 xlsx 下载
|
||||
const MAX_EXPORT_ROWS = 2000;
|
||||
const MAX_EXPORT_COLUMNS = 40;
|
||||
|
||||
router.post('/export', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const body = req.body || {};
|
||||
const spuCode = normalizeText(body.spu, 160).toUpperCase() || 'SPU';
|
||||
const columns = Array.isArray(body.columns) ? body.columns : [];
|
||||
const rows = Array.isArray(body.rows) ? body.rows : [];
|
||||
|
||||
const validColumns = columns
|
||||
.filter((col) => col && typeof col === 'object')
|
||||
.slice(0, MAX_EXPORT_COLUMNS)
|
||||
.map((col) => ({
|
||||
key: normalizeText(col.key, 60),
|
||||
label: normalizeText(col.label, 60) || normalizeText(col.key, 60),
|
||||
percent: col.percent === true,
|
||||
}))
|
||||
.filter((col) => col.key);
|
||||
|
||||
if (validColumns.length === 0) {
|
||||
return res.status(400).json({ success: false, error: '缺少导出列配置' });
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return res.status(400).json({ success: false, error: '导出数据为空' });
|
||||
}
|
||||
if (rows.length > MAX_EXPORT_ROWS) {
|
||||
return res.status(400).json({ success: false, error: `单次最多导出 ${MAX_EXPORT_ROWS} 行` });
|
||||
}
|
||||
|
||||
try {
|
||||
const aoa = [validColumns.map((col) => col.label)];
|
||||
rows.forEach((row) => {
|
||||
aoa.push(validColumns.map((col) => {
|
||||
const value = row && typeof row === 'object' ? row[col.key] : null;
|
||||
return value === null || value === undefined ? '' : value;
|
||||
}));
|
||||
});
|
||||
const sheet = XLSX.utils.aoa_to_sheet(aoa);
|
||||
// 百分比列:保持数值,设置百分比数字格式(0.06 在 Excel 显示为 6.00%)
|
||||
validColumns.forEach((col, colIndex) => {
|
||||
if (!col.percent) return;
|
||||
for (let rowIndex = 1; rowIndex < aoa.length; rowIndex += 1) {
|
||||
const addr = XLSX.utils.encode_cell({ c: colIndex, r: rowIndex });
|
||||
const cell = sheet[addr];
|
||||
if (cell && typeof cell.v === 'number') {
|
||||
cell.z = '0.00%';
|
||||
}
|
||||
}
|
||||
});
|
||||
sheet['!cols'] = validColumns.map((col) => ({ wch: Math.max(12, String(col.label).length * 2 + 6) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, '上架测算');
|
||||
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
|
||||
const stamp = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const filename = `上架测算_${spuCode}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: `导出失败:${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 用户个性化默认值(每运营维护自己的配置,无配置时前端用系统默认) =====
|
||||
const PREFS_TABLE = 'pricing_calculator_user_prefs';
|
||||
let prefsTableReady = null;
|
||||
|
||||
async function ensurePrefsTable() {
|
||||
if (!prefsTableReady) {
|
||||
prefsTableReady = (async () => {
|
||||
const existing = await database.local.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[PREFS_TABLE]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return;
|
||||
}
|
||||
await database.local.query(
|
||||
`CREATE TABLE IF NOT EXISTS ${PREFS_TABLE} (
|
||||
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
|
||||
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算用户个性化默认值'`
|
||||
);
|
||||
})();
|
||||
}
|
||||
try {
|
||||
await prefsTableReady;
|
||||
} catch (error) {
|
||||
prefsTableReady = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 允许的偏好字段与取值范围(targetMargin 为小数,0.15 = 15%)
|
||||
const PREF_FIELD_RANGES = {
|
||||
coupon: { min: 0, max: 100000 },
|
||||
activityDiscount: { min: 0.01, max: 1 },
|
||||
installFee: { min: 0, max: 100000 },
|
||||
targetMargin: { min: -1, max: 1 },
|
||||
};
|
||||
|
||||
function sanitizePrefs(input) {
|
||||
const src = input && typeof input === 'object' ? input : {};
|
||||
const prefs = {};
|
||||
Object.entries(PREF_FIELD_RANGES).forEach(([key, range]) => {
|
||||
const num = Number(src[key]);
|
||||
if (Number.isFinite(num)) {
|
||||
prefs[key] = Math.min(range.max, Math.max(range.min, num));
|
||||
}
|
||||
});
|
||||
return prefs;
|
||||
}
|
||||
|
||||
const PREFS_PROFILE_KEY = '_profiles';
|
||||
const PREFS_PROFILES = new Set(['company', 'alibaba1688']);
|
||||
|
||||
function normalizePrefsProfile(value) {
|
||||
return PREFS_PROFILES.has(value) ? value : 'company';
|
||||
}
|
||||
|
||||
function parsePrefsStorage(value) {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readPrefsProfile(storageValue, profile = 'company') {
|
||||
const storage = parsePrefsStorage(storageValue);
|
||||
const normalizedProfile = normalizePrefsProfile(profile);
|
||||
const source = normalizedProfile === 'company'
|
||||
? storage
|
||||
: storage[PREFS_PROFILE_KEY]?.[normalizedProfile];
|
||||
const prefs = sanitizePrefs(source);
|
||||
return Object.keys(prefs).length > 0 ? prefs : null;
|
||||
}
|
||||
|
||||
function writePrefsProfile(storageValue, profile, prefs) {
|
||||
const storage = parsePrefsStorage(storageValue);
|
||||
const normalizedProfile = normalizePrefsProfile(profile);
|
||||
if (normalizedProfile === 'company') {
|
||||
return { ...storage, ...prefs };
|
||||
}
|
||||
return {
|
||||
...storage,
|
||||
[PREFS_PROFILE_KEY]: {
|
||||
...(storage[PREFS_PROFILE_KEY] && typeof storage[PREFS_PROFILE_KEY] === 'object'
|
||||
? storage[PREFS_PROFILE_KEY]
|
||||
: {}),
|
||||
[normalizedProfile]: prefs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPrefsPatch(profile, prefs) {
|
||||
const normalizedProfile = normalizePrefsProfile(profile);
|
||||
return normalizedProfile === 'company'
|
||||
? prefs
|
||||
: { [PREFS_PROFILE_KEY]: { [normalizedProfile]: prefs } };
|
||||
}
|
||||
|
||||
async function persistPrefsProfile(userId, profile, prefs) {
|
||||
const patch = JSON.stringify(createPrefsPatch(profile, prefs));
|
||||
try {
|
||||
// JSON_MERGE_PATCH 在数据库内原子合并,保留另一测算类型的配置,且只需一次网络往返。
|
||||
await database.local.query(
|
||||
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH(
|
||||
IF(JSON_VALID(prefs_json), prefs_json, '{}'),
|
||||
VALUES(prefs_json)
|
||||
)`,
|
||||
[userId, patch]
|
||||
);
|
||||
} catch (error) {
|
||||
const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET']
|
||||
.includes(error.code);
|
||||
if (!canFallback) throw error;
|
||||
|
||||
// 兼容不支持 JSON_MERGE_PATCH 的旧版 MySQL / MariaDB。
|
||||
const existingRows = await database.local.query(
|
||||
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
const storage = writePrefsProfile(existingRows[0]?.prefs_json, profile, prefs);
|
||||
await database.local.query(
|
||||
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`,
|
||||
[userId, JSON.stringify(storage)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/prefs', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
try {
|
||||
await ensurePrefsTable();
|
||||
const rows = await database.local.query(
|
||||
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
const profile = normalizePrefsProfile(req.query.profile);
|
||||
const prefs = rows.length > 0
|
||||
? readPrefsProfile(rows[0].prefs_json, profile)
|
||||
: null;
|
||||
res.json({ success: true, data: { prefs } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取个性化配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/prefs', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
const prefs = sanitizePrefs(req.body?.prefs ?? req.body);
|
||||
const profile = normalizePrefsProfile(req.body?.profile);
|
||||
if (Object.keys(prefs).length === 0) {
|
||||
return res.status(400).json({ success: false, error: '缺少有效的配置项' });
|
||||
}
|
||||
try {
|
||||
await ensurePrefsTable();
|
||||
await persistPrefsProfile(userId, profile, prefs);
|
||||
res.json({ success: true, data: { prefs } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存个性化配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports._test = {
|
||||
filterSkuRowsForSelectionContext,
|
||||
createPrefsPatch,
|
||||
getSelectionSkuCodeSet,
|
||||
normalizeCalculatorType,
|
||||
readPrefsProfile,
|
||||
sanitizeHistoryProduct,
|
||||
sanitizePrefs,
|
||||
writePrefsProfile,
|
||||
};
|
||||
73
backend/src/api/pricingCalculatorExternal.js
Normal file
73
backend/src/api/pricingCalculatorExternal.js
Normal file
@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const pricingCalculatorRoutes = require('./pricingCalculator');
|
||||
const {
|
||||
requireDesktopPageAccess,
|
||||
requireExternalAutomationApiKey,
|
||||
} = require('../services/externalAutomationAuth');
|
||||
|
||||
const router = express.Router();
|
||||
const MAX_OPERATOR_NAME_LENGTH = 100;
|
||||
const MAX_SAFE_ID = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
function normalizeOperatorName(value) {
|
||||
return String(value || '').trim().slice(0, MAX_OPERATOR_NAME_LENGTH);
|
||||
}
|
||||
|
||||
function decodeOperatorNameHeader(value) {
|
||||
const encoded = String(value || '').trim();
|
||||
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function desktopPricingUserId(operatorName) {
|
||||
const normalized = normalizeOperatorName(operatorName).toLocaleLowerCase('zh-CN');
|
||||
if (!normalized) return null;
|
||||
const digest = crypto.createHash('sha256').update(`desktop-pricing:${normalized}`, 'utf8').digest();
|
||||
const value = (digest.readBigUInt64BE(0) % (MAX_SAFE_ID - 1n)) + 1n;
|
||||
return -Number(value);
|
||||
}
|
||||
|
||||
function requireDesktopPricingOperator(req, res, next) {
|
||||
if (req.externalApiKey?.authType === 'jwt' && req.user?.name) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const operatorName = normalizeOperatorName(
|
||||
decodeOperatorNameHeader(req.get('x-operator-name-encoded'))
|
||||
|| req.get('x-operator-name')
|
||||
|| req.query?.operatorName
|
||||
|| req.body?.operatorName
|
||||
);
|
||||
if (!operatorName) {
|
||||
return res.status(400).json({ success: false, error: '缺少运营花名' });
|
||||
}
|
||||
|
||||
const userId = desktopPricingUserId(operatorName);
|
||||
req.user = {
|
||||
id: userId,
|
||||
sub: userId,
|
||||
name: operatorName,
|
||||
username: operatorName,
|
||||
role: 'operator',
|
||||
authSource: 'desktop-api-key',
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
router.use(
|
||||
requireExternalAutomationApiKey,
|
||||
requireDesktopPageAccess('planning_upload'),
|
||||
requireDesktopPricingOperator,
|
||||
pricingCalculatorRoutes
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
module.exports.desktopPricingUserId = desktopPricingUserId;
|
||||
module.exports.requireDesktopPricingOperator = requireDesktopPricingOperator;
|
||||
1739
backend/src/api/productAnalysis.js
Normal file
1739
backend/src/api/productAnalysis.js
Normal file
File diff suppressed because it is too large
Load Diff
73
backend/src/api/productMonitoring.js
Normal file
73
backend/src/api/productMonitoring.js
Normal file
@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getMonitoringList,
|
||||
getMonitoringFilters,
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
getProductOperators,
|
||||
} = require('../services/productMonitoringService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '商品监控请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ProductMonitoring]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 明细列表(含公司标签 / 部门标签 / 负责人 / 汇总)
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMonitoringList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载商品监控列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 筛选下拉选项
|
||||
router.get('/filters', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMonitoringFilters() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载筛选选项失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||
router.get('/operators', async (req, res) => {
|
||||
try {
|
||||
const sku = req.query?.sku || req.query?.skuoid;
|
||||
if (!sku) {
|
||||
return res.status(400).json({ success: false, error: '缺少 sku 参数' });
|
||||
}
|
||||
return res.json({ success: true, data: { operators: await getProductOperators(sku) } });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '加载运营明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 部门标签 + 负责人配置(系统设置用)
|
||||
router.get('/department-config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getDepartmentTagConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/department-config', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
417
backend/src/api/promotionModule.js
Normal file
417
backend/src/api/promotionModule.js
Normal file
@ -0,0 +1,417 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const middleDb = require('../config/middlePlatformDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function fmtMoney(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? (n * 100).toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function getDateRange(period, customStart, customEnd) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (period) {
|
||||
case 'realtime': {
|
||||
const d = new Date(today);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'yesterday': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 1);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'week': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1));
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last7d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last30d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 29);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'custom': {
|
||||
return {
|
||||
start: customStart || formatDate(today),
|
||||
end: customEnd || formatDate(today),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移)
|
||||
function getPreviousRange(range) {
|
||||
const start = new Date(`${range.start}T00:00:00`);
|
||||
const end = new Date(`${range.end}T00:00:00`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
const prevEnd = new Date(start);
|
||||
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - (lengthDays - 1));
|
||||
return { start: formatDate(prevStart), end: formatDate(prevEnd) };
|
||||
}
|
||||
|
||||
const PROMOTION_SUMMARY_SELECT = `SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgRoi,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgNetRoi,
|
||||
SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum,
|
||||
CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum]))
|
||||
ELSE 0 END AS avgCostPerNetOrder
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`;
|
||||
|
||||
async function queryPromotionSummary(range) {
|
||||
const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, {
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
});
|
||||
const stats = row || {};
|
||||
return {
|
||||
totalRecords: toNum(stats.totalRecords),
|
||||
orderSpend: toNum(stats.totalOrderSpend),
|
||||
gmv: toNum(stats.totalGmv),
|
||||
roi: toNum(stats.avgRoi),
|
||||
spend: toNum(stats.totalSpend),
|
||||
netGmv: toNum(stats.totalNetGmv),
|
||||
netRoi: toNum(stats.avgNetRoi),
|
||||
netOrderNum: toNum(stats.totalNetOrderNum),
|
||||
costPerNetOrder: toNum(stats.avgCostPerNetOrder),
|
||||
};
|
||||
}
|
||||
|
||||
// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null
|
||||
function buildPeriodChanges(current, previous) {
|
||||
const changes = {};
|
||||
Object.keys(current).forEach((key) => {
|
||||
const base = Math.abs(previous[key]);
|
||||
changes[key] = base > 0 ? (current[key] - previous[key]) / base : null;
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function getImageMap(goodsIds) {
|
||||
if (!goodsIds.length) return new Map();
|
||||
try {
|
||||
const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(',');
|
||||
const params = {};
|
||||
goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); });
|
||||
const rows = await middleDb.query(
|
||||
`SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`,
|
||||
params
|
||||
);
|
||||
const map = new Map();
|
||||
rows.forEach((row) => {
|
||||
if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl);
|
||||
});
|
||||
return map;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || '');
|
||||
res.json({ success: true, data: { date: String(date || '').trim() } });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/shops', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT [shopName]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != ''
|
||||
ORDER BY [shopName]`
|
||||
);
|
||||
res.json({ success: true, data: rows.map((r) => r.shopName) });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询店铺列表失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date, period, start: customStart, end: customEnd } = req.query;
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
let range;
|
||||
|
||||
if (period === 'custom') {
|
||||
range = getDateRange('custom', customStart, customEnd);
|
||||
} else if (period) {
|
||||
range = getDateRange(period);
|
||||
filterDate = '';
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
// 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期)
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
range = { start: filterDate, end: filterDate };
|
||||
}
|
||||
|
||||
const previousRange = getPreviousRange(range);
|
||||
const [current, previous] = await Promise.all([
|
||||
queryPromotionSummary(range),
|
||||
previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate: `${range.start} ~ ${range.end}`,
|
||||
previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '',
|
||||
totalRecords: current.totalRecords,
|
||||
orderSpend: fmtMoney(current.orderSpend),
|
||||
gmv: fmtMoney(current.gmv),
|
||||
roi: fmtNum(current.roi),
|
||||
spend: fmtMoney(current.spend),
|
||||
netGmv: fmtMoney(current.netGmv),
|
||||
netRoi: fmtNum(current.netRoi),
|
||||
netOrderNum: fmtNum(current.netOrderNum),
|
||||
costPerNetOrder: fmtMoney(current.costPerNetOrder),
|
||||
changes: previous ? buildPeriodChanges(current, previous) : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'orderSpend',
|
||||
sortOrder = 'DESC',
|
||||
keyword,
|
||||
shopName,
|
||||
date,
|
||||
period,
|
||||
start: customStart,
|
||||
end: customEnd,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
if (period && period !== 'custom') {
|
||||
const range = getDateRange(period);
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = range.start;
|
||||
params.endDate = range.end;
|
||||
} else if (period === 'custom' && customStart && customEnd) {
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = customStart;
|
||||
params.endDate = customEnd;
|
||||
} else {
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyword && keyword.trim()) {
|
||||
whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)');
|
||||
params.kw = `%${keyword.trim()}%`;
|
||||
}
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] = @shopName');
|
||||
params.shopName = shopName.trim();
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const allowedSort = new Set([
|
||||
'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi',
|
||||
'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount',
|
||||
'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr',
|
||||
]);
|
||||
const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]';
|
||||
const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[goodsName],
|
||||
[goodsId],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[orderSpend],
|
||||
[gmv],
|
||||
[roi],
|
||||
[spend],
|
||||
[orderNum],
|
||||
[transactionCost],
|
||||
[avgPayAmount],
|
||||
[directGmv],
|
||||
[indirectGmv],
|
||||
[directOrderNum],
|
||||
[indirectOrderNum],
|
||||
[netGmv],
|
||||
[netRoi],
|
||||
[netOrderNum],
|
||||
[netAvgPayAmount],
|
||||
[netRate],
|
||||
[settlementGmv],
|
||||
[settlementRoi],
|
||||
[settlementOrder],
|
||||
[refundGmvRate30d],
|
||||
[refundOrderRate30d],
|
||||
[exemptRefundGmvRate30d],
|
||||
[exemptRefundOrderRate30d],
|
||||
[settlementGmvRate],
|
||||
[settlementOrderRate],
|
||||
[costPerSettlementOrderForDeal],
|
||||
[impression],
|
||||
[click],
|
||||
[ctr],
|
||||
[cvr],
|
||||
[targetRoi],
|
||||
[targetBid]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
${whereSql}
|
||||
ORDER BY ${sortCol} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean);
|
||||
const imageMap = await getImageMap(goodsIds);
|
||||
|
||||
const list = dataResult.map((row) => ({
|
||||
...row,
|
||||
thumbUrl: imageMap.get(String(row.goodsId)) || '',
|
||||
orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null,
|
||||
gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null,
|
||||
spend: row.spend != null ? toNum(row.spend) / 1000.0 : null,
|
||||
transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null,
|
||||
avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null,
|
||||
directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null,
|
||||
indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null,
|
||||
netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null,
|
||||
netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null,
|
||||
settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null,
|
||||
costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询推广数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
999
backend/src/api/storeCheckup.js
Normal file
999
backend/src/api/storeCheckup.js
Normal file
@ -0,0 +1,999 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
const META_CACHE_TTL_MS = 60 * 1000;
|
||||
const OVERVIEW_CACHE_TTL_MS = 60 * 1000;
|
||||
const HOME_SUMMARY_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const HOME_SUMMARY_STALE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const HOME_COMPENSATION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const HOME_COMPENSATION_SAMPLE_SIZE = 10000;
|
||||
const metaCache = { value: null, expiresAt: 0, promise: null };
|
||||
const overviewCache = new Map();
|
||||
const overviewInFlight = new Map();
|
||||
const homeSummaryCache = new Map();
|
||||
const homeSummaryInFlight = new Map();
|
||||
const homeReviewCache = new Map();
|
||||
const homeReviewInFlight = new Map();
|
||||
const homeCompensationCache = new Map();
|
||||
const homeCompensationSummaryCache = new Map();
|
||||
const homeCompensationSummaryInFlight = new Map();
|
||||
|
||||
const TAB_KEYS = new Set(['overview', 'business', 'after-sales', 'customer-service', 'service-score', 'stars']);
|
||||
|
||||
function normalizeDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function addDays(value, offset) {
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function inclusiveDayCount(startDate, endDate) {
|
||||
const start = new Date(`${startDate}T00:00:00Z`);
|
||||
const end = new Date(`${endDate}T00:00:00Z`);
|
||||
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function normalizeCompensationDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function compensationDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : '';
|
||||
}
|
||||
|
||||
async function loadRecentCompensationRows(shopName = '') {
|
||||
const cacheKey = shopName || '*';
|
||||
const cached = homeCompensationCache.get(cacheKey);
|
||||
if (cached?.rows && cached.expiresAt > Date.now()) return cached.rows;
|
||||
if (cached?.promise) return cached.promise;
|
||||
|
||||
const promise = jianSunDb.query(
|
||||
`SELECT TOP (${HOME_COMPENSATION_SAMPLE_SIZE})
|
||||
id, chargeAmount, chargeReason, chargeTime, couponDetailId,
|
||||
goodsId, goodsName, orderSn, shopName
|
||||
FROM dbo.pdd_abnormal_compensation WITH (NOLOCK)
|
||||
WHERE (@shopName = '' OR shopName = @shopName)
|
||||
ORDER BY id DESC`,
|
||||
{ shopName }
|
||||
).then((rows) => {
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
date: compensationDate(row.chargeTime),
|
||||
amount: toNumber(row.chargeAmount),
|
||||
reason: String(row.chargeReason || '').trim() || '未分类',
|
||||
goodsId: String(row.goodsId || '').trim(),
|
||||
goodsName: String(row.goodsName || '').trim(),
|
||||
orderSn: String(row.orderSn || '').trim(),
|
||||
detailId: String(row.couponDetailId || '').trim(),
|
||||
shopName: String(row.shopName || '').trim(),
|
||||
}))
|
||||
.filter((row) => row.date);
|
||||
homeCompensationCache.set(cacheKey, {
|
||||
rows: normalizedRows,
|
||||
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
|
||||
promise: null,
|
||||
});
|
||||
if (homeCompensationCache.size > 12) {
|
||||
const firstKey = homeCompensationCache.keys().next().value;
|
||||
homeCompensationCache.delete(firstKey);
|
||||
}
|
||||
return normalizedRows;
|
||||
}).finally(() => {
|
||||
const entry = homeCompensationCache.get(cacheKey);
|
||||
if (entry?.promise) homeCompensationCache.delete(cacheKey);
|
||||
});
|
||||
homeCompensationCache.set(cacheKey, { rows: null, expiresAt: 0, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function resolveCompensationDateRange(req, rows) {
|
||||
const latestAvailableDate = rows.reduce((latest, row) => (row.date > latest ? row.date : latest), '');
|
||||
const endDate = normalizeCompensationDate(req.query.endDate) || latestAvailableDate;
|
||||
const startDate = normalizeCompensationDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
|
||||
if (!startDate || !endDate) {
|
||||
const error = new Error('赔付数据没有可用日期');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const dayCount = inclusiveDayCount(startDate, endDate);
|
||||
if (dayCount < 1) {
|
||||
const error = new Error('赔付开始日期不能晚于结束日期');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (dayCount > 90) {
|
||||
const error = new Error('赔付查询日期范围不能超过90天');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return { startDate, endDate, dayCount, latestAvailableDate };
|
||||
}
|
||||
|
||||
function createCompensationGroup(row) {
|
||||
return {
|
||||
amount: 0,
|
||||
recordCount: 0,
|
||||
orderKeys: new Set(),
|
||||
...row,
|
||||
};
|
||||
}
|
||||
|
||||
function addCompensationRecord(group, row) {
|
||||
group.amount += row.amount;
|
||||
group.recordCount += 1;
|
||||
group.orderKeys.add(row.orderSn || row.detailId || String(row.id));
|
||||
}
|
||||
|
||||
function queryHomeCompensationSummary(rows, range) {
|
||||
const selectedRows = rows.filter((row) => row.date >= range.startDate && row.date <= range.endDate);
|
||||
const orderKeys = new Set();
|
||||
const goodsKeys = new Set();
|
||||
const reasonGroups = new Map();
|
||||
const goodsGroups = new Map();
|
||||
let totalAmount = 0;
|
||||
|
||||
selectedRows.forEach((row) => {
|
||||
const orderKey = row.orderSn || row.detailId || String(row.id);
|
||||
orderKeys.add(orderKey);
|
||||
if (row.goodsId) goodsKeys.add(row.goodsId);
|
||||
totalAmount += row.amount;
|
||||
|
||||
const reasonGroup = reasonGroups.get(row.reason) || createCompensationGroup({ reason: row.reason });
|
||||
addCompensationRecord(reasonGroup, row);
|
||||
reasonGroups.set(row.reason, reasonGroup);
|
||||
|
||||
const goodsKey = row.goodsId || `unknown-${row.id}`;
|
||||
const goodsGroup = goodsGroups.get(goodsKey) || createCompensationGroup({
|
||||
goodsId: row.goodsId || '未识别商品ID',
|
||||
goodsName: row.goodsName,
|
||||
shopName: row.shopName,
|
||||
});
|
||||
addCompensationRecord(goodsGroup, row);
|
||||
if (!goodsGroup.goodsName && row.goodsName) goodsGroup.goodsName = row.goodsName;
|
||||
if (!goodsGroup.shopName && row.shopName) goodsGroup.shopName = row.shopName;
|
||||
goodsGroups.set(goodsKey, goodsGroup);
|
||||
});
|
||||
|
||||
const toGroupResult = (group) => ({
|
||||
...group,
|
||||
orderCount: group.orderKeys.size,
|
||||
amount: Number(group.amount.toFixed(2)),
|
||||
share: totalAmount > 0 ? Number((group.amount * 100 / totalAmount).toFixed(2)) : 0,
|
||||
orderKeys: undefined,
|
||||
});
|
||||
const reasonDistribution = [...reasonGroups.values()]
|
||||
.map(toGroupResult)
|
||||
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
|
||||
.slice(0, 6);
|
||||
const highCompensationGoods = [...goodsGroups.values()]
|
||||
.map(toGroupResult)
|
||||
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
...range,
|
||||
sampledRecordCount: rows.length,
|
||||
selectedRecordCount: selectedRows.length,
|
||||
compensationCount: orderKeys.size,
|
||||
recordCount: selectedRows.length,
|
||||
totalAmount: Number(totalAmount.toFixed(2)),
|
||||
averageAmount: orderKeys.size ? Number((totalAmount / orderKeys.size).toFixed(2)) : 0,
|
||||
affectedGoodsCount: goodsKeys.size,
|
||||
reasonDistribution,
|
||||
highCompensationGoods,
|
||||
};
|
||||
}
|
||||
|
||||
function toNullableNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function averageNullable(rows, field) {
|
||||
const values = rows.map((row) => toNullableNumber(row[field])).filter((value) => value !== null);
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
||||
}
|
||||
|
||||
function cleanShopName(value) {
|
||||
const text = String(value || '').trim();
|
||||
return text || '';
|
||||
}
|
||||
|
||||
function shopFilterSql() {
|
||||
return '(@shopName IS NULL OR @shopName = \'\' OR shopName = @shopName)';
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
if (metaCache.value && metaCache.expiresAt > Date.now()) return metaCache.value;
|
||||
if (metaCache.promise) return metaCache.promise;
|
||||
|
||||
metaCache.promise = Promise.all([
|
||||
jianSunDb.query(
|
||||
`SELECT
|
||||
shopName,
|
||||
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
|
||||
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
|
||||
FROM dbo.pdd_DataNormal
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
GROUP BY shopName
|
||||
ORDER BY shopName`
|
||||
),
|
||||
jianSunDb.query(
|
||||
`SELECT
|
||||
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
|
||||
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
|
||||
FROM dbo.pdd_DataNormal
|
||||
WHERE statDate IS NOT NULL`
|
||||
),
|
||||
]).then(([shopRows, dateRows]) => {
|
||||
const shops = shopRows
|
||||
.map((row) => ({
|
||||
name: cleanShopName(row.shopName),
|
||||
minDate: normalizeDate(row.minDate),
|
||||
latestDate: normalizeDate(row.latestDate),
|
||||
}))
|
||||
.filter((row) => row.name);
|
||||
const globalDate = dateRows[0] || {};
|
||||
const data = {
|
||||
shops,
|
||||
minDate: normalizeDate(globalDate.minDate),
|
||||
latestDate: normalizeDate(globalDate.latestDate),
|
||||
};
|
||||
metaCache.value = data;
|
||||
metaCache.expiresAt = Date.now() + META_CACHE_TTL_MS;
|
||||
return data;
|
||||
}).finally(() => {
|
||||
metaCache.promise = null;
|
||||
});
|
||||
|
||||
return metaCache.promise;
|
||||
}
|
||||
|
||||
async function resolveContext(req) {
|
||||
const meta = await loadMeta();
|
||||
const shopName = cleanShopName(req.query.shopName);
|
||||
const shop = shopName ? meta.shops.find((item) => item.name === shopName) : null;
|
||||
const requestedDate = normalizeDate(req.query.date);
|
||||
const date = requestedDate || shop?.latestDate || meta.latestDate;
|
||||
return { meta, shopName, date };
|
||||
}
|
||||
|
||||
async function queryLatestRows({ table, dateColumn, fields, targetDate, shopName }) {
|
||||
const rows = await jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT ${fields},
|
||||
ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.${table}
|
||||
WHERE ${shopFilterSql()}
|
||||
AND [${dateColumn}] = @targetDate
|
||||
)
|
||||
SELECT * FROM ranked WHERE rowNumber = 1`,
|
||||
{ targetDate, shopName: shopName || null }
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function queryBusinessSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_DataNormal',
|
||||
dateColumn: 'statDate',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, statDate, cfmOrdrAmt, payOrdrAup, payUvRto',
|
||||
});
|
||||
const sales = rows.reduce((sum, row) => sum + toNumber(row.cfmOrdrAmt), 0);
|
||||
const averageConversion = averageNullable(rows, 'payUvRto');
|
||||
const count = rows.length;
|
||||
return {
|
||||
shopCount: count,
|
||||
sales,
|
||||
averageAov: averageNullable(rows, 'payOrdrAup'),
|
||||
averageConversion: averageConversion === null ? null : averageConversion * 100,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryAfterSalesSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_ShopNormalOverflow',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
|
||||
});
|
||||
const fields = ['executedCount', 'afterSalesCount', 'waitSellerHandle', 'platformHandling', 'waitBuyerHandle', 'todosCount', 'todosEndedCount', 'cfmOrdrCnt'];
|
||||
const data = fields.reduce((result, field) => ({ ...result, [field]: rows.reduce((sum, row) => sum + toNumber(row[field]), 0) }), {});
|
||||
return { ...data, shopCount: rows.length, rows };
|
||||
}
|
||||
|
||||
async function queryCustomerServiceSnapshot(targetDate, shopName) {
|
||||
const [refundRows, receptionRows] = await Promise.all([
|
||||
queryLatestRows({
|
||||
table: 'pdd_DataCustomerService',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, dsptRfSucOrdrCnt1m, dsptRfSucRto1m, pltInvlOrdrCnt1m, pltInvlOrdrRto1m, qurfOrdRto1m, avgSucRfProcTime1m, ssslAvgSucRfProcTime1mPct, sucRfOrdrCnt1d, sucRfOrdrAmt1d, rfSucRto1m, avgSlfSucRfProcTime1mMgr, avgSlfSucRfProcTime1mMr',
|
||||
}),
|
||||
jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.pdd_customer_overflow
|
||||
WHERE ${shopFilterSql()}
|
||||
AND [createTime] = @targetDate
|
||||
)
|
||||
SELECT
|
||||
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
|
||||
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
|
||||
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
|
||||
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
|
||||
SUM(ISNULL(need_manu_reply_consult_user_cnt, 0)) AS needManualReplyCount,
|
||||
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
|
||||
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
|
||||
SUM(ISNULL(unsatisfied_serve_score_cnt, 0)) AS unsatisfiedScoreCount,
|
||||
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), avg_reply_time)) AS averageReplyTime,
|
||||
SUM(ISNULL(serve_refund_cnt, 0)) AS serviceRefundCount,
|
||||
COUNT(DISTINCT cs_name) AS customerServiceCount
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1 AND cs_name = N'店铺总计'`,
|
||||
{ targetDate, shopName: shopName || null }
|
||||
),
|
||||
]);
|
||||
|
||||
const refundFields = [
|
||||
'dsptRfSucOrdrCnt1m', 'pltInvlOrdrCnt1m', 'sucRfOrdrCnt1d', 'sucRfOrdrAmt1d',
|
||||
];
|
||||
const refundTotals = refundFields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: refundRows.reduce((sum, row) => sum + toNumber(row[field]), 0),
|
||||
}), {});
|
||||
const averageFields = [
|
||||
'dsptRfSucRto1m', 'pltInvlOrdrRto1m', 'qurfOrdRto1m', 'avgSucRfProcTime1m',
|
||||
'ssslAvgSucRfProcTime1mPct', 'rfSucRto1m', 'avgSlfSucRfProcTime1mMgr', 'avgSlfSucRfProcTime1mMr',
|
||||
];
|
||||
const averages = averageFields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: averageNullable(refundRows, field),
|
||||
}), {});
|
||||
return {
|
||||
...refundTotals,
|
||||
...averages,
|
||||
shopCount: refundRows.length,
|
||||
reception: receptionRows[0] || {},
|
||||
rows: refundRows,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryServiceScoreSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_MallServiceScore',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, cstmrServScore, attiLmScore, jcfwLmScore, spLmScore, fhLmScore, wlLmScore',
|
||||
});
|
||||
const fields = ['cstmrServScore', 'attiLmScore', 'jcfwLmScore', 'spLmScore', 'fhLmScore', 'wlLmScore'];
|
||||
const values = fields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: averageNullable(rows, field),
|
||||
}), {});
|
||||
return { ...values, shopCount: rows.length, rows };
|
||||
}
|
||||
|
||||
async function queryStarsSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_ShopStars',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, mallStarTomms, mallStarTommsChange, mallStarDesc, ptHelpRate1m, aprsIn3minRplyUsrRto1m, rfSignTime1m, rfProcTime1m, dsr3m, posTypeRto1m, seriousInferiorRto1m, adjNfkAvgCfmSignTime1m, lgstMergeFine1m, gmvRk, ptHelpRate1mNextStarRkDesc, ptHelpRate1mNextPctThreshold, aprsIn3minRplyUsrRto1mNextStarRkDesc, aprsIn3minRplyUsrRto1mNextPctThreshold, rfSignTime1mNextStarRkDesc, rfSignTime1mNextPctThreshold, rfProcTime1mNextStarRkDesc, rfProcTime1mNextPctThreshold, dsr3mNextStarRkDesc, dsr3mNextPctThreshold, posTypeRto1mNextStarRkDesc, posTypeRto1mNextPctThreshold, seriousInferiorRto1mNextStarRkDesc, seriousInferiorRto1mNextPctThreshold, adjNfkAvgCfmSignTime1mNextStarRkDesc, adjNfkAvgCfmSignTime1mNextPctThreshold, lgstMergeFine1mNextStarRkDesc, lgstMergeFine1mNextPctThreshold, gmvRkNextStarRkDesc, gmvRkNextPctThreshold, scoreRk',
|
||||
});
|
||||
const averageFields = ['mallStarTomms', 'mallStarTommsChange', 'ptHelpRate1m', 'aprsIn3minRplyUsrRto1m', 'rfSignTime1m', 'rfProcTime1m', 'dsr3m', 'posTypeRto1m', 'seriousInferiorRto1m', 'adjNfkAvgCfmSignTime1m', 'lgstMergeFine1m', 'gmvRk'];
|
||||
const values = averageFields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: averageNullable(rows, field),
|
||||
}), {});
|
||||
const first = rows[0] || {};
|
||||
const targetFields = [
|
||||
['ptHelpRate1m', '平台求助率', 'ptHelpRate1mNextStarRkDesc', 'ptHelpRate1mNextPctThreshold'],
|
||||
['aprsIn3minRplyUsrRto1m', '3分钟人工回复率', 'aprsIn3minRplyUsrRto1mNextStarRkDesc', 'aprsIn3minRplyUsrRto1mNextPctThreshold'],
|
||||
['rfSignTime1m', '在途订单退款时长', 'rfSignTime1mNextStarRkDesc', 'rfSignTime1mNextPctThreshold'],
|
||||
['rfProcTime1m', '签收退货后退款时长', 'rfProcTime1mNextStarRkDesc', 'rfProcTime1mNextPctThreshold'],
|
||||
['dsr3m', '90天用户评价得分', 'dsr3mNextStarRkDesc', 'dsr3mNextPctThreshold'],
|
||||
['posTypeRto1m', '积极评论率', 'posTypeRto1mNextStarRkDesc', 'posTypeRto1mNextPctThreshold'],
|
||||
['seriousInferiorRto1m', '严重劣质率', 'seriousInferiorRto1mNextStarRkDesc', 'seriousInferiorRto1mNextPctThreshold'],
|
||||
['adjNfkAvgCfmSignTime1m', '成团到签收时效', 'adjNfkAvgCfmSignTime1mNextStarRkDesc', 'adjNfkAvgCfmSignTime1mNextPctThreshold'],
|
||||
['lgstMergeFine1m', '物流综合处罚率', 'lgstMergeFine1mNextStarRkDesc', 'lgstMergeFine1mNextPctThreshold'],
|
||||
['gmvRk', '店铺活跃度', 'gmvRkNextStarRkDesc', 'gmvRkNextPctThreshold'],
|
||||
];
|
||||
const targets = shopName ? targetFields.map(([field, label, statusField, thresholdField]) => ({
|
||||
field,
|
||||
label,
|
||||
value: toNullableNumber(first[field]),
|
||||
status: first[statusField] || null,
|
||||
threshold: toNullableNumber(first[thresholdField]),
|
||||
})) : [];
|
||||
const targetFailureCount = rows.reduce((count, row) => count + targetFields.filter(([, , statusField]) => includesFailure(row[statusField])).length, 0);
|
||||
return { ...values, shopCount: rows.length, targets, targetFailureCount, rows };
|
||||
}
|
||||
|
||||
function includesFailure(value) {
|
||||
const text = String(value || '').trim();
|
||||
return text.includes('不达标') || text.includes('未达标') || text.includes('下降');
|
||||
}
|
||||
|
||||
function buildIssues({ afterSales, customerService, stars }) {
|
||||
const issues = [];
|
||||
const addIssue = (key, tone, title, value, description, source) => {
|
||||
if (toNumber(value) > 0 || includesFailure(value)) {
|
||||
issues.push({ key, tone, title, value, description, source });
|
||||
}
|
||||
};
|
||||
addIssue('wait-seller-handle', 'error', '待商家处理售后单', afterSales.waitSellerHandle, '需要优先处理,避免售后时效继续积压', '店铺售后');
|
||||
addIssue('platform-handling', 'warning', '平台处理中售后单', afterSales.platformHandling, '平台介入中的售后订单需要关注处理结果', '店铺售后');
|
||||
addIssue('delay-reply', 'warning', '三分钟未回复咨询', customerService.reception.delayReplyCount, '客服响应存在延迟', '客服接待');
|
||||
addIssue('complaint', 'error', '客服投诉数', customerService.reception.complainUserCount, '存在客户投诉,需要回溯客服接待记录', '客服接待');
|
||||
addIssue('star-target', 'warning', '星级指标存在未达标项', stars.targetFailureCount, '可在“星级提升”中查看具体指标和下一星级标准', '店铺星级');
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function queryOverview(context) {
|
||||
const { shopName, date } = context;
|
||||
const [business, afterSales, customerService, serviceScore, stars] = await Promise.all([
|
||||
queryBusinessSnapshot(date, shopName),
|
||||
queryAfterSalesSnapshot(date, shopName),
|
||||
queryCustomerServiceSnapshot(date, shopName),
|
||||
queryServiceScoreSnapshot(date, shopName),
|
||||
queryStarsSnapshot(date, shopName),
|
||||
]);
|
||||
const reception = customerService.reception || {};
|
||||
return {
|
||||
date,
|
||||
shopName: shopName || '全部店铺',
|
||||
shopCount: business.shopCount,
|
||||
business: {
|
||||
sales: business.sales,
|
||||
averageAov: business.averageAov,
|
||||
averageConversion: business.averageConversion,
|
||||
},
|
||||
afterSales: {
|
||||
afterSalesCount: afterSales.afterSalesCount,
|
||||
executedCount: afterSales.executedCount,
|
||||
waitSellerHandle: afterSales.waitSellerHandle,
|
||||
platformHandling: afterSales.platformHandling,
|
||||
waitBuyerHandle: afterSales.waitBuyerHandle,
|
||||
todosCount: afterSales.todosCount,
|
||||
todosEndedCount: afterSales.todosEndedCount,
|
||||
cfmOrdrCnt: afterSales.cfmOrdrCnt,
|
||||
},
|
||||
customerService: {
|
||||
...customerService,
|
||||
reception: {
|
||||
consultUserCount: toNumber(reception.consultUserCount),
|
||||
inquiryUserCount: toNumber(reception.inquiryUserCount),
|
||||
finalGroupUserCount: toNumber(reception.finalGroupUserCount),
|
||||
csSalesAmount: toNumber(reception.csSalesAmount),
|
||||
needManualReplyCount: toNumber(reception.needManualReplyCount),
|
||||
receiveUserCount: toNumber(reception.receiveUserCount),
|
||||
delayReplyCount: toNumber(reception.delayReplyCount),
|
||||
replyRate3Min: toNullableNumber(reception.replyRate3Min),
|
||||
replyRate30s: toNullableNumber(reception.replyRate30s),
|
||||
unsatisfiedScoreCount: toNumber(reception.unsatisfiedScoreCount),
|
||||
complainUserCount: toNumber(reception.complainUserCount),
|
||||
averageReplyTime: toNullableNumber(reception.averageReplyTime),
|
||||
serviceRefundCount: toNumber(reception.serviceRefundCount),
|
||||
customerServiceCount: toNumber(reception.customerServiceCount),
|
||||
},
|
||||
},
|
||||
serviceScore: {
|
||||
cstmrServScore: serviceScore.cstmrServScore,
|
||||
attiLmScore: serviceScore.attiLmScore,
|
||||
jcfwLmScore: serviceScore.jcfwLmScore,
|
||||
spLmScore: serviceScore.spLmScore,
|
||||
fhLmScore: serviceScore.fhLmScore,
|
||||
wlLmScore: serviceScore.wlLmScore,
|
||||
},
|
||||
stars: {
|
||||
mallStarTomms: stars.mallStarTomms,
|
||||
mallStarTommsChange: stars.mallStarTommsChange,
|
||||
dsr3m: stars.dsr3m,
|
||||
ptHelpRate1m: stars.ptHelpRate1m,
|
||||
aprsIn3minRplyUsrRto1m: stars.aprsIn3minRplyUsrRto1m,
|
||||
targets: stars.targets,
|
||||
},
|
||||
issues: buildIssues({ afterSales, customerService, stars }),
|
||||
coverage: {
|
||||
business: business.shopCount > 0,
|
||||
afterSales: afterSales.shopCount > 0,
|
||||
customerService: customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
|
||||
serviceScore: serviceScore.shopCount > 0,
|
||||
stars: stars.shopCount > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function queryHomeSummary(context) {
|
||||
const { shopName, date } = context;
|
||||
const [afterSales, customerService, serviceScore, stars] = await Promise.all([
|
||||
queryAfterSalesSnapshot(date, shopName),
|
||||
queryCustomerServiceSnapshot(date, shopName),
|
||||
queryServiceScoreSnapshot(date, shopName),
|
||||
queryStarsSnapshot(date, shopName),
|
||||
]);
|
||||
const reception = customerService.reception || {};
|
||||
const riskItems = [
|
||||
{
|
||||
key: 'waitSellerHandle',
|
||||
label: '待商家处理',
|
||||
value: toNumber(afterSales.waitSellerHandle),
|
||||
tone: 'error',
|
||||
},
|
||||
{
|
||||
key: 'delayReplyCount',
|
||||
label: '三分钟未回复',
|
||||
value: toNumber(reception.delayReplyCount),
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'complainUserCount',
|
||||
label: '客服投诉',
|
||||
value: toNumber(reception.complainUserCount),
|
||||
tone: 'error',
|
||||
},
|
||||
{
|
||||
key: 'starTargetFailure',
|
||||
label: '星级未达标项',
|
||||
value: toNumber(stars.targetFailureCount),
|
||||
tone: 'warning',
|
||||
},
|
||||
];
|
||||
const riskSignalCount = riskItems.reduce((sum, item) => sum + item.value, 0);
|
||||
const riskTypeCount = riskItems.filter((item) => item.value > 0).length;
|
||||
const coverageModules = [
|
||||
afterSales.shopCount > 0,
|
||||
customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
|
||||
serviceScore.shopCount > 0,
|
||||
stars.shopCount > 0,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return {
|
||||
date,
|
||||
shopName: shopName || '全部店铺',
|
||||
shopCount: Math.max(
|
||||
toNumber(afterSales.shopCount),
|
||||
toNumber(customerService.shopCount),
|
||||
toNumber(serviceScore.shopCount),
|
||||
toNumber(stars.shopCount)
|
||||
),
|
||||
totalShopCount: shopName ? 1 : context.meta.shops.length,
|
||||
coverageModules,
|
||||
totalModules: 4,
|
||||
status: riskSignalCount > 0 ? 'attention' : 'healthy',
|
||||
riskSignalCount,
|
||||
riskTypeCount,
|
||||
averageStar: toNullableNumber(stars.mallStarTomms),
|
||||
starChange: toNullableNumber(stars.mallStarTommsChange),
|
||||
evaluationScore: toNullableNumber(stars.dsr3m),
|
||||
riskItems,
|
||||
serviceScores: [
|
||||
{ key: 'cstmrServScore', label: '服务体验', value: toNullableNumber(serviceScore.cstmrServScore) },
|
||||
{ key: 'spLmScore', label: '商品服务', value: toNullableNumber(serviceScore.spLmScore) },
|
||||
{ key: 'wlLmScore', label: '物流服务', value: toNullableNumber(serviceScore.wlLmScore) },
|
||||
{ key: 'fhLmScore', label: '发货服务', value: toNullableNumber(serviceScore.fhLmScore) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReviewDateRange(req) {
|
||||
const shopName = cleanShopName(req.query.shopName);
|
||||
let endDate = normalizeDate(req.query.endDate);
|
||||
if (!endDate) {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT TOP (1) LEFT(createTime, 10) AS latestDate
|
||||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||||
WHERE createTime >= '2000-01-01'
|
||||
AND createTime < '2100-01-01'
|
||||
AND descScore BETWEEN 1 AND 5
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
ORDER BY createTime DESC`,
|
||||
{ shopName }
|
||||
);
|
||||
endDate = normalizeDate(rows[0]?.latestDate);
|
||||
}
|
||||
const startDate = normalizeDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
|
||||
if (!startDate || !endDate) {
|
||||
const error = new Error('评价数据没有可用日期');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const dayCount = inclusiveDayCount(startDate, endDate);
|
||||
if (dayCount < 1) {
|
||||
const error = new Error('评价开始日期不能晚于结束日期');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (dayCount > 90) {
|
||||
const error = new Error('评价查询日期范围不能超过90天');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return { startDate, endDate, dayCount, shopName };
|
||||
}
|
||||
|
||||
async function queryHomeReviewSummary(range) {
|
||||
const params = {
|
||||
startDate: range.startDate,
|
||||
endDateExclusive: addDays(range.endDate, 1),
|
||||
shopName: range.shopName || '',
|
||||
};
|
||||
const [summaryRows, keywordRows] = await Promise.all([
|
||||
jianSunDb.query(
|
||||
`SELECT
|
||||
COUNT_BIG(1) AS totalReviewCount,
|
||||
SUM(CASE WHEN descScore IN (4, 5) THEN 1 ELSE 0 END) AS goodReviewCount,
|
||||
SUM(CASE WHEN descScore IN (1, 2, 3) THEN 1 ELSE 0 END) AS badReviewCount,
|
||||
SUM(CASE WHEN descScore = 1 THEN 1 ELSE 0 END) AS score1Count,
|
||||
SUM(CASE WHEN descScore = 2 THEN 1 ELSE 0 END) AS score2Count,
|
||||
SUM(CASE WHEN descScore = 3 THEN 1 ELSE 0 END) AS score3Count,
|
||||
SUM(CASE WHEN descScore = 4 THEN 1 ELSE 0 END) AS score4Count,
|
||||
SUM(CASE WHEN descScore = 5 THEN 1 ELSE 0 END) AS score5Count,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), descScore)) AS averageScore,
|
||||
COUNT(DISTINCT goodsId) AS reviewedGoodsCount,
|
||||
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(shopName)), '')) AS reviewedShopCount
|
||||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||||
WHERE createTime >= @startDate
|
||||
AND createTime < @endDateExclusive
|
||||
AND descScore BETWEEN 1 AND 5
|
||||
AND (@shopName = '' OR shopName = @shopName)`,
|
||||
params
|
||||
),
|
||||
jianSunDb.query(
|
||||
`SELECT keywords, COUNT_BIG(1) AS reviewCount
|
||||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||||
WHERE createTime >= @startDate
|
||||
AND createTime < @endDateExclusive
|
||||
AND descScore BETWEEN 1 AND 3
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
AND NULLIF(LTRIM(RTRIM(keywords)), '') IS NOT NULL
|
||||
GROUP BY keywords`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
const row = summaryRows[0] || {};
|
||||
const totalReviewCount = toNumber(row.totalReviewCount);
|
||||
const goodReviewCount = toNumber(row.goodReviewCount);
|
||||
const badReviewCount = toNumber(row.badReviewCount);
|
||||
const scoreCounts = [1, 2, 3, 4, 5].map((score) => ({
|
||||
score,
|
||||
count: toNumber(row[`score${score}Count`]),
|
||||
}));
|
||||
const negativeKeywordPattern = /差|不符|异味|瑕疵|破损|低|少发|错发|不好|问题|慢|难/;
|
||||
const badKeywordCounts = new Map();
|
||||
keywordRows.forEach((item) => {
|
||||
const weight = toNumber(item.reviewCount);
|
||||
String(item.keywords || '').split(/[,,]/).forEach((value) => {
|
||||
const keyword = value.trim();
|
||||
if (!keyword || !negativeKeywordPattern.test(keyword)) return;
|
||||
badKeywordCounts.set(keyword, (badKeywordCounts.get(keyword) || 0) + weight);
|
||||
});
|
||||
});
|
||||
const badKeywords = [...badKeywordCounts.entries()]
|
||||
.map(([keyword, count]) => ({ keyword, count }))
|
||||
.sort((left, right) => right.count - left.count || left.keyword.localeCompare(right.keyword, 'zh-CN'))
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
...range,
|
||||
totalReviewCount,
|
||||
goodReviewCount,
|
||||
goodReviewRate: totalReviewCount > 0 ? goodReviewCount * 100 / totalReviewCount : 0,
|
||||
badReviewCount,
|
||||
badReviewRate: totalReviewCount > 0 ? badReviewCount * 100 / totalReviewCount : 0,
|
||||
averageScore: toNullableNumber(row.averageScore),
|
||||
reviewedGoodsCount: toNumber(row.reviewedGoodsCount),
|
||||
reviewedShopCount: toNumber(row.reviewedShopCount),
|
||||
distribution: scoreCounts.map((item) => ({
|
||||
...item,
|
||||
rate: totalReviewCount > 0 ? item.count * 100 / totalReviewCount : 0,
|
||||
})),
|
||||
badKeywords,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryBusinessTab(context) {
|
||||
const startDate = addDays(context.date, -29);
|
||||
const rows = await jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT
|
||||
statDate,
|
||||
shopName, cfmOrdrAmt, payOrdrAup, payUvRto,
|
||||
ROW_NUMBER() OVER (PARTITION BY shopName, statDate ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.pdd_DataNormal
|
||||
WHERE ${shopFilterSql()}
|
||||
AND statDate BETWEEN @startDate AND @targetDate
|
||||
)
|
||||
SELECT statDate,
|
||||
SUM(TRY_CONVERT(decimal(38, 6), cfmOrdrAmt)) AS sales,
|
||||
AVG(TRY_CONVERT(decimal(38, 6), payOrdrAup)) AS averageAov,
|
||||
AVG(TRY_CONVERT(decimal(38, 8), payUvRto)) AS averageConversion,
|
||||
COUNT(*) AS shopCount
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1
|
||||
GROUP BY statDate
|
||||
ORDER BY statDate`,
|
||||
{ startDate, targetDate: context.date, shopName: context.shopName || '' }
|
||||
);
|
||||
return {
|
||||
status: rows.length ? 'ready' : 'empty',
|
||||
metrics: [
|
||||
{ key: 'sales', label: '成交额', value: rows.reduce((sum, row) => sum + toNumber(row.sales), 0), unit: 'currency' },
|
||||
{ key: 'averageAov', label: '平均支付客单价', value: rows.length ? toNumber(rows.at(-1).averageAov) : null, unit: 'currency' },
|
||||
{ key: 'averageConversion', label: '最新支付转化率', value: rows.length ? toNumber(rows.at(-1).averageConversion) * 100 : null, unit: 'percent' },
|
||||
],
|
||||
trend: rows.map((row) => ({ date: row.statDate, sales: toNumber(row.sales), averageAov: toNumber(row.averageAov), averageConversion: toNumber(row.averageConversion) * 100 })),
|
||||
};
|
||||
}
|
||||
|
||||
async function queryAfterSalesTab(context) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_ShopNormalOverflow',
|
||||
dateColumn: 'createTime',
|
||||
targetDate: context.date,
|
||||
shopName: context.shopName,
|
||||
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
|
||||
});
|
||||
return {
|
||||
status: rows.length ? 'ready' : 'empty',
|
||||
rows,
|
||||
columns: [
|
||||
{ key: 'shopName', title: '店铺' }, { key: 'afterSalesCount', title: '售后总量' }, { key: 'executedCount', title: '已处理' },
|
||||
{ key: 'waitSellerHandle', title: '待商家处理' }, { key: 'platformHandling', title: '平台处理中' }, { key: 'waitBuyerHandle', title: '待买家处理' },
|
||||
{ key: 'todosCount', title: '工单总量' }, { key: 'todosEndedCount', title: '已完结工单' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function queryCustomerServiceTab(context) {
|
||||
const rows = await jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.pdd_customer_overflow
|
||||
WHERE ${shopFilterSql()}
|
||||
AND [createTime] = @targetDate
|
||||
)
|
||||
SELECT
|
||||
cs_name AS csName,
|
||||
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
|
||||
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
|
||||
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
|
||||
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
|
||||
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
|
||||
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
|
||||
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1 AND ISNULL(cs_name, '') <> N'店铺总计'
|
||||
GROUP BY cs_name
|
||||
ORDER BY SUM(ISNULL(cs_sales_amount, 0)) DESC`,
|
||||
{ targetDate: context.date, shopName: context.shopName || '' }
|
||||
);
|
||||
return {
|
||||
status: rows.length ? 'ready' : 'empty',
|
||||
rows,
|
||||
columns: [
|
||||
{ key: 'csName', title: '客服账号' }, { key: 'consultUserCount', title: '咨询人数' }, { key: 'inquiryUserCount', title: '询单人数' },
|
||||
{ key: 'finalGroupUserCount', title: '成团人数' }, { key: 'csSalesAmount', title: '客服销售额' }, { key: 'receiveUserCount', title: '人工接待' },
|
||||
{ key: 'replyRate30s', title: '30秒应答率' }, { key: 'replyRate3Min', title: '3分钟回复率' }, { key: 'complainUserCount', title: '投诉数' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function queryServiceScoreTab(context) {
|
||||
const snapshot = await queryServiceScoreSnapshot(context.date, context.shopName);
|
||||
return {
|
||||
status: snapshot.shopCount ? 'ready' : 'empty',
|
||||
metrics: [
|
||||
{ key: 'cstmrServScore', label: '消费者服务体验分', value: snapshot.cstmrServScore, unit: 'score' },
|
||||
{ key: 'attiLmScore', label: '服务态度', value: snapshot.attiLmScore, unit: 'score' },
|
||||
{ key: 'jcfwLmScore', label: '基础服务', value: snapshot.jcfwLmScore, unit: 'score' },
|
||||
{ key: 'spLmScore', label: '商品服务', value: snapshot.spLmScore, unit: 'score' },
|
||||
{ key: 'fhLmScore', label: '发货服务', value: snapshot.fhLmScore, unit: 'score' },
|
||||
{ key: 'wlLmScore', label: '物流服务', value: snapshot.wlLmScore, unit: 'score' },
|
||||
],
|
||||
rows: snapshot.rows,
|
||||
columns: [
|
||||
{ key: 'shopName', title: '店铺' }, { key: 'cstmrServScore', title: '消费者服务体验分' }, { key: 'attiLmScore', title: '服务态度' },
|
||||
{ key: 'jcfwLmScore', title: '基础服务' }, { key: 'spLmScore', title: '商品服务' }, { key: 'fhLmScore', title: '发货服务' }, { key: 'wlLmScore', title: '物流服务' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function queryStarsTab(context) {
|
||||
const snapshot = await queryStarsSnapshot(context.date, context.shopName);
|
||||
return {
|
||||
status: snapshot.shopCount ? 'ready' : 'empty',
|
||||
metrics: [
|
||||
{ key: 'mallStarTomms', label: '综合体验星级', value: snapshot.mallStarTomms, unit: 'star' },
|
||||
{ key: 'mallStarTommsChange', label: '星级变化', value: snapshot.mallStarTommsChange, unit: 'starChange' },
|
||||
{ key: 'dsr3m', label: '90天用户评价得分', value: snapshot.dsr3m, unit: 'score' },
|
||||
{ key: 'ptHelpRate1m', label: '30天平台求助率', value: snapshot.ptHelpRate1m, unit: 'ratio' },
|
||||
{ key: 'aprsIn3minRplyUsrRto1m', label: '30天3分钟人工回复率', value: snapshot.aprsIn3minRplyUsrRto1m, unit: 'ratio' },
|
||||
],
|
||||
targets: snapshot.targets,
|
||||
rows: snapshot.rows,
|
||||
columns: [
|
||||
{ key: 'shopName', title: '店铺' }, { key: 'mallStarTomms', title: '综合体验星级' }, { key: 'mallStarTommsChange', title: '星级变化' },
|
||||
{ key: 'dsr3m', title: '90天评价得分' }, { key: 'ptHelpRate1m', title: '平台求助率' }, { key: 'aprsIn3minRplyUsrRto1m', title: '3分钟回复率' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/meta', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await loadMeta() });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询元数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/overview', async (req, res) => {
|
||||
try {
|
||||
const context = await resolveContext(req);
|
||||
const cacheKey = `${context.shopName || '*'}:${context.date}`;
|
||||
const cached = overviewCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
let pending = overviewInFlight.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = queryOverview(context)
|
||||
.then((data) => {
|
||||
overviewCache.set(cacheKey, { data, expiresAt: Date.now() + OVERVIEW_CACHE_TTL_MS });
|
||||
return data;
|
||||
})
|
||||
.finally(() => overviewInFlight.delete(cacheKey));
|
||||
overviewInFlight.set(cacheKey, pending);
|
||||
}
|
||||
const data = await pending;
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询体检概览失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/home-summary', async (req, res) => {
|
||||
try {
|
||||
const context = await resolveContext(req);
|
||||
const cacheKey = `${context.shopName || '*'}:${context.date}`;
|
||||
const cached = homeSummaryCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
const refreshSummary = () => {
|
||||
let pending = homeSummaryInFlight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
pending = queryHomeSummary(context)
|
||||
.then((data) => {
|
||||
const now = Date.now();
|
||||
homeSummaryCache.set(cacheKey, {
|
||||
data,
|
||||
expiresAt: now + HOME_SUMMARY_CACHE_TTL_MS,
|
||||
staleUntil: now + HOME_SUMMARY_STALE_TTL_MS,
|
||||
});
|
||||
return data;
|
||||
})
|
||||
.finally(() => homeSummaryInFlight.delete(cacheKey));
|
||||
homeSummaryInFlight.set(cacheKey, pending);
|
||||
return pending;
|
||||
};
|
||||
if (cached && cached.staleUntil > Date.now()) {
|
||||
refreshSummary().catch((error) => {
|
||||
console.error('[StoreCheckup] 后台刷新首页摘要失败:', error.message);
|
||||
});
|
||||
return res.json({ success: true, data: { ...cached.data, refreshing: true } });
|
||||
}
|
||||
return res.json({ success: true, data: await refreshSummary() });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询首页摘要失败:', error.message);
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/home-reviews', async (req, res) => {
|
||||
try {
|
||||
const range = await resolveReviewDateRange(req);
|
||||
const cacheKey = `${range.shopName || '*'}:${range.startDate}:${range.endDate}`;
|
||||
const cached = homeReviewCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
let pending = homeReviewInFlight.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = queryHomeReviewSummary(range)
|
||||
.then((data) => {
|
||||
homeReviewCache.set(cacheKey, { data, expiresAt: Date.now() + HOME_SUMMARY_CACHE_TTL_MS });
|
||||
return data;
|
||||
})
|
||||
.finally(() => homeReviewInFlight.delete(cacheKey));
|
||||
homeReviewInFlight.set(cacheKey, pending);
|
||||
}
|
||||
return res.json({ success: true, data: await pending });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询首页评价摘要失败:', error.message);
|
||||
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/home-compensation', async (req, res) => {
|
||||
try {
|
||||
const shopName = cleanShopName(req.query.shopName);
|
||||
const rows = await loadRecentCompensationRows(shopName);
|
||||
const range = { ...(await resolveCompensationDateRange(req, rows)), shopName };
|
||||
const cacheKey = `${shopName || '*'}:${range.startDate}:${range.endDate}`;
|
||||
const cached = homeCompensationSummaryCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
let pending = homeCompensationSummaryInFlight.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = Promise.resolve(queryHomeCompensationSummary(rows, range))
|
||||
.then((data) => {
|
||||
homeCompensationSummaryCache.set(cacheKey, {
|
||||
data,
|
||||
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
|
||||
});
|
||||
return data;
|
||||
})
|
||||
.finally(() => homeCompensationSummaryInFlight.delete(cacheKey));
|
||||
homeCompensationSummaryInFlight.set(cacheKey, pending);
|
||||
}
|
||||
return res.json({ success: true, data: await pending });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询首页赔付摘要失败:', error.message);
|
||||
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tabs/:tabKey', async (req, res) => {
|
||||
try {
|
||||
const tabKey = String(req.params.tabKey || '').trim();
|
||||
if (!TAB_KEYS.has(tabKey) || tabKey === 'overview') {
|
||||
return res.status(400).json({ success: false, error: '不支持的店铺体检模块' });
|
||||
}
|
||||
const context = await resolveContext(req);
|
||||
const handlers = {
|
||||
business: queryBusinessTab,
|
||||
'after-sales': queryAfterSalesTab,
|
||||
'customer-service': queryCustomerServiceTab,
|
||||
'service-score': queryServiceScoreTab,
|
||||
stars: queryStarsTab,
|
||||
};
|
||||
const data = await handlers[tabKey](context);
|
||||
return res.json({ success: true, data: { ...data, date: context.date, shopName: context.shopName || '全部店铺' } });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询体检模块失败:', error.message);
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -41,15 +41,13 @@ function buildRedisClientConfig(config) {
|
||||
return {
|
||||
host: config.host,
|
||||
port: parseInt(config.port || '6379', 10),
|
||||
username: config.username || process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
|
||||
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
|
||||
password: config.password || undefined,
|
||||
db: parseInt(config.db || '0', 10),
|
||||
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
|
||||
connectTimeout: REDIS_CONNECT_TIMEOUT_MS,
|
||||
retryStrategy: (times) => (times > 1 ? null : 200),
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
};
|
||||
}
|
||||
@ -63,10 +61,8 @@ function getConfigSignature(config) {
|
||||
host: config.host,
|
||||
port: parseInt(config.port || '6379', 10),
|
||||
db: parseInt(config.db || '0', 10),
|
||||
username: config.username || process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
|
||||
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
|
||||
password: config.password || '',
|
||||
tls: Boolean(config.tls),
|
||||
tlsServerName: config.tlsServerName || '',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -14,12 +14,26 @@ const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) =
|
||||
});
|
||||
|
||||
const SIDEBAR_PAGES = [
|
||||
buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }),
|
||||
buildPlanningPage({
|
||||
pageKey: 'dashboard_preview',
|
||||
pageName: '仪表盘(开发预览)',
|
||||
path: '/planning/dev-style-preview',
|
||||
}),
|
||||
buildPlanningPage({
|
||||
pageKey: 'marketing_events',
|
||||
pageName: '营销活动',
|
||||
path: '/planning/operation/marketing-events',
|
||||
allowSubPaths: true,
|
||||
}),
|
||||
buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }),
|
||||
buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }),
|
||||
buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }),
|
||||
buildPlanningPage({ pageKey: 'store_checkup', pageName: '店铺体检', path: '/planning/operation/store-checkup' }),
|
||||
buildPlanningPage({ pageKey: 'review_analysis', pageName: '复盘分析', path: '/planning/operation/review-analysis' }),
|
||||
buildPlanningPage({ pageKey: 'customer_service_checkup', pageName: '客服质检', path: '/planning/operation/customer-service-checkup' }),
|
||||
buildPlanningPage({ pageKey: 'ai_buyer_show', pageName: 'AI买家秀', path: '/planning/operation/ai-buyer-show' }),
|
||||
buildPlanningPage({ pageKey: 'ai_seeding_video', pageName: 'AI种草视频', path: '/planning/operation/ai-seeding-video' }),
|
||||
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
|
||||
{
|
||||
pageKey: 'settings',
|
||||
|
||||
@ -19,6 +19,7 @@ const planningSelectionFissionExternalRoutes = require('./api/planningSelectionF
|
||||
const presaleExternalRoutes = require('./api/presaleExternal');
|
||||
const delistExternalRoutes = require('./api/delistExternal');
|
||||
const campaignCloudRoutes = require('./api/campaignCloud');
|
||||
const pricingCalculatorExternalRoutes = require('./api/pricingCalculatorExternal');
|
||||
const desktopClientRoutes = require('./api/desktopClient');
|
||||
const desktopPresenceRoutes = require('./api/desktopPresence');
|
||||
const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks');
|
||||
@ -29,7 +30,14 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
|
||||
const channelRoutes = require('./api/channel');
|
||||
const analysisRoutes = require('./api/analysis');
|
||||
const productCodeBoardRoutes = require('./api/productCodeBoard');
|
||||
const dashboardRoutes = require('./api/dashboard');
|
||||
const productAnalysisRoutes = require('./api/productAnalysis');
|
||||
const marketingEventsRoutes = require('./api/marketingEvents');
|
||||
const promotionModuleRoutes = require('./api/promotionModule');
|
||||
const csQualityRoutes = require('./api/csQuality');
|
||||
const aiBuyerShowRoutes = require('./api/aiBuyerShow');
|
||||
const storeCheckupRoutes = require('./api/storeCheckup');
|
||||
const pricingCalculatorRoutes = require('./api/pricingCalculator');
|
||||
const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const database = require('./config/database');
|
||||
const cloudDb = require('./config/cloudDatabase');
|
||||
const jianSunDb = require('./config/jianSunDatabase');
|
||||
@ -40,6 +48,8 @@ const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductE
|
||||
const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
|
||||
const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
|
||||
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
|
||||
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
|
||||
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
|
||||
const {
|
||||
startAlibaba1688ImageSearchExpiryScheduler,
|
||||
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
||||
@ -123,6 +133,7 @@ function shouldHandlePlanningUploadPageCache(req) {
|
||||
req.path === '/'
|
||||
|| req.path.startsWith('/table/may_product_data')
|
||||
|| req.path.startsWith('/analysis/may-channel-insight')
|
||||
|| req.path.startsWith('/analysis/ops-b-incentive')
|
||||
|| req.path.startsWith('/selection-fission')
|
||||
) {
|
||||
return false;
|
||||
@ -625,6 +636,7 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes);
|
||||
cubejsApp.use('/api/campaign', campaignCloudRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission/pricing-calculator', pricingCalculatorExternalRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes);
|
||||
cubejsApp.use('/api/external/presale', presaleExternalRoutes);
|
||||
@ -640,9 +652,16 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
|
||||
cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
|
||||
cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes);
|
||||
cubejsApp.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
||||
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
|
||||
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
|
||||
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
|
||||
cubejsApp.use('/api/cs-quality', authMiddleware, csQualityRoutes);
|
||||
cubejsApp.use('/api/ai-buyer-show', authMiddleware, aiBuyerShowRoutes);
|
||||
cubejsApp.use('/api/store-checkup', authMiddleware, storeCheckupRoutes);
|
||||
cubejsApp.use('/api/pricing-calculator', authMiddleware, pricingCalculatorRoutes);
|
||||
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
|
||||
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
|
||||
// =====================
|
||||
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||
@ -708,6 +727,8 @@ async function startServer(options = {}) {
|
||||
startPddSkuDimensionSnapshotSchedulers();
|
||||
startPlanningMayProductSnapshotSchedulers();
|
||||
startPlanningDingWorkflowSyncSchedulers();
|
||||
startOpsBIncentiveSnapshotSchedulers();
|
||||
startGoodsFirstSeenScheduler();
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
|
||||
// Keep the first selection-pool page request from paying the remote schema
|
||||
|
||||
164
backend/src/jobs/goodsFirstSeenJob.js
Normal file
164
backend/src/jobs/goodsFirstSeenJob.js
Normal file
@ -0,0 +1,164 @@
|
||||
const cron = require('node-cron');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const planningDb = require('../services/planningDatabase');
|
||||
const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const SYNC_CHUNK_SIZE = 500;
|
||||
const BACKFILL_CHUNK_SIZE = 800;
|
||||
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
|
||||
|
||||
let schemaEnsured = false;
|
||||
|
||||
async function ensureTable() {
|
||||
if (schemaEnsured) return;
|
||||
await planningDb.query(`
|
||||
CREATE TABLE IF NOT EXISTS pdd_goods_first_seen (
|
||||
goods_id VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)',
|
||||
last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期',
|
||||
is_onsale TINYINT NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
schemaEnsured = true;
|
||||
}
|
||||
|
||||
function epochToDatetime(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return null;
|
||||
const ms = num >= 1e12 ? num : num * 1000;
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at)
|
||||
async function syncCaptureDate(captureDate) {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId,
|
||||
MIN(s.createdAt) AS minCreatedAt,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index))
|
||||
WHERE s.createTime = @captureDate
|
||||
AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> ''
|
||||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`,
|
||||
{ captureDate },
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
|
||||
const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
|
||||
return {
|
||||
captureDate,
|
||||
goodsInBatch: records.length,
|
||||
upserted: records.length,
|
||||
newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)),
|
||||
total: Number(countAfter[0]?.n || 0),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验)
|
||||
async function refreshLatestCapture() {
|
||||
const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10));
|
||||
if (!captureDate) {
|
||||
console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步');
|
||||
return { skipped: true };
|
||||
}
|
||||
return syncCaptureDate(captureDate);
|
||||
}
|
||||
|
||||
// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制)
|
||||
async function backfillAllHistory() {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const goodsRows = await planningDb.query(
|
||||
`SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})`
|
||||
);
|
||||
const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))];
|
||||
console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`);
|
||||
|
||||
const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
let synced = 0;
|
||||
for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) {
|
||||
const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE);
|
||||
const params = {};
|
||||
const placeholders = chunk.map((goodsId, index) => {
|
||||
params[`g${index}`] = Number(goodsId);
|
||||
return `@g${index}`;
|
||||
}).join(', ');
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT s.goodsId AS goodsId,
|
||||
MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt,
|
||||
MAX(s.createTime) AS lastCaptureRaw,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus))
|
||||
WHERE s.goodsId IN (${placeholders})
|
||||
GROUP BY s.goodsId`,
|
||||
params,
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
const lastCapture = epochToDatetime(row.lastCaptureRaw);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(i, i + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
synced += records.length;
|
||||
console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`);
|
||||
}
|
||||
const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt };
|
||||
console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function startGoodsFirstSeenScheduler() {
|
||||
const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
refreshLatestCapture().catch((error) => {
|
||||
console.error('[GoodsFirstSeen] 定时同步失败:', error.message);
|
||||
});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler };
|
||||
31
backend/src/jobs/opsBIncentiveSnapshotJob.js
Normal file
31
backend/src/jobs/opsBIncentiveSnapshotJob.js
Normal file
@ -0,0 +1,31 @@
|
||||
const cron = require('node-cron');
|
||||
const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
async function runOpsBIncentiveSnapshotJob(source = 'scheduled') {
|
||||
try {
|
||||
const result = await refreshOpsBIncentiveSnapshots(source);
|
||||
console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startOpsBIncentiveSnapshotSchedulers() {
|
||||
// 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。
|
||||
const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob };
|
||||
375
backend/src/services/aiBuyerShowReviewService.js
Normal file
375
backend/src/services/aiBuyerShowReviewService.js
Normal file
@ -0,0 +1,375 @@
|
||||
const axios = require('axios');
|
||||
const database = require('../config/database');
|
||||
|
||||
const DEFAULT_REVIEW_TOTAL = 10;
|
||||
const REVIEW_TOTAL_OPTIONS = [5, 10, 20, 40, 60, 80, 100];
|
||||
const REVIEW_BATCH_SIZE = 5;
|
||||
const REVIEW_MAX_CONCURRENCY = 4;
|
||||
const REVIEW_MAX_REFILL_ATTEMPTS = 3;
|
||||
|
||||
const SUPPORTED_SCENES = [
|
||||
'卧室', '书房', '客厅', '餐厅', '阳台', '入户门', '玄关',
|
||||
'办公室', '户外', '厨房', '电竞房',
|
||||
];
|
||||
const SUPPORTED_USER_TYPES = [
|
||||
'自己', '妈妈', '爸爸', '儿子', '女儿', '老公', '老婆', '男朋友',
|
||||
'女朋友', '闺蜜', '朋友', '同事', '室友', '老人', '孩子', '学生',
|
||||
'上班族', '租房党', '新婚家庭', '宝妈', '宠物家庭', '客户', '领导',
|
||||
];
|
||||
|
||||
function normalizeMultiInput(value) {
|
||||
const raw = Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+/);
|
||||
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = true) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function getSystemConfigMap(keys) {
|
||||
try {
|
||||
const placeholders = keys.map(() => '?').join(', ');
|
||||
const rows = await database.local.query(
|
||||
`SELECT config_key AS configKey, config_value AS configValue
|
||||
FROM system_config
|
||||
WHERE config_key IN (${placeholders})`,
|
||||
keys
|
||||
);
|
||||
return new Map(rows.map((row) => [row.configKey, String(row.configValue || '').trim()]));
|
||||
} catch (_) {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function getRuntimeConfig() {
|
||||
const keys = [
|
||||
'ai_buyer_show_ark_api_key',
|
||||
'ai_buyer_show_ark_base_url',
|
||||
'ai_buyer_show_vision_model',
|
||||
'ai_buyer_show_review_model',
|
||||
];
|
||||
const stored = await getSystemConfigMap(keys);
|
||||
const apiKey = String(process.env.ARK_API_KEY || stored.get(keys[0]) || '').trim();
|
||||
if (!apiKey) {
|
||||
const error = new Error('未配置豆包 ARK_API_KEY,无法生成攻心评价');
|
||||
error.statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
const baseUrl = process.env.ARK_BASE_URL
|
||||
|| stored.get(keys[1])
|
||||
|| 'https://ark.cn-beijing.volces.com/api/v3';
|
||||
const visionModel = process.env.VISION_MODEL
|
||||
|| stored.get(keys[2])
|
||||
|| 'doubao-1-5-vision-pro-32k-250115';
|
||||
const reviewModel = process.env.PDD_HEART_REVIEW_MODEL
|
||||
|| process.env.DOUBAO_TEXT_MODEL
|
||||
|| stored.get(keys[3])
|
||||
|| visionModel;
|
||||
return { apiKey, baseUrl, visionModel, reviewModel };
|
||||
}
|
||||
|
||||
function buildImageDataUrl(buffer, mimeType) {
|
||||
const type = String(mimeType || '').startsWith('image/') ? mimeType : 'image/jpeg';
|
||||
return `data:${type};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
function parseJsonContent(content, fallback) {
|
||||
const trimmed = String(content || '')
|
||||
.trim()
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/^```\s*/i, '')
|
||||
.replace(/```$/i, '')
|
||||
.trim();
|
||||
if (!trimmed) return fallback;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_) {
|
||||
const candidate = trimmed.match(/\[[\s\S]*\]/)?.[0]
|
||||
|| trimmed.match(/\{[\s\S]*\}/)?.[0];
|
||||
if (!candidate) return fallback;
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function callChatCompletion(messages, modelType, options = {}) {
|
||||
const config = await getRuntimeConfig();
|
||||
const model = modelType === 'vision' ? config.visionModel : config.reviewModel;
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${String(config.baseUrl).replace(/\/$/, '')}/chat/completions`,
|
||||
{
|
||||
model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.68,
|
||||
...(options.maxTokens ? { max_tokens: options.maxTokens } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 180000,
|
||||
}
|
||||
);
|
||||
return {
|
||||
content: response.data?.choices?.[0]?.message?.content || '',
|
||||
model: response.data?.model || model,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.error?.message
|
||||
|| error.response?.data?.message
|
||||
|| error.response?.data?.error
|
||||
|| error.message
|
||||
|| 'AI接口调用失败';
|
||||
const wrapped = new Error(`AI接口调用失败:${detail}`);
|
||||
wrapped.statusCode = error.response?.status || 502;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeImage({ imageBuffer, mimeType, scenes, userTypes, sellingPoints }) {
|
||||
const prompt = [
|
||||
'你是拼多多商品评价运营助手。请识别商品图片,并结合运营填写的信息,严格返回 JSON 对象。',
|
||||
'只返回 JSON,不要 markdown,不要解释。',
|
||||
'JSON 字段:productCategory、productNameGuess、furnitureColors、visualScene、visualSellingPoints、userPainPoints、emotionalAngles。',
|
||||
`运营选择场景:${scenes.length ? scenes.join('、') : '未填写,请根据图片判断最合适场景'}`,
|
||||
`运营选择使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
|
||||
`运营填写卖点:${sellingPoints}`,
|
||||
].join('\n');
|
||||
const result = await callChatCompletion([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: buildImageDataUrl(imageBuffer, mimeType) } },
|
||||
],
|
||||
}], 'vision');
|
||||
return { analysis: parseJsonContent(result.content, {}), model: result.model };
|
||||
}
|
||||
|
||||
function normalizeReviewText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim();
|
||||
}
|
||||
|
||||
function stripEmoji(value) {
|
||||
return String(value || '')
|
||||
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '')
|
||||
.replace(/\p{Extended_Pictographic}/gu, '')
|
||||
.replace(/[\u{FE0E}\u{FE0F}\u{200D}]/gu, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeReviewItem(value, useEmoji) {
|
||||
const source = typeof value === 'string' ? { reviewContent: value } : (value || {});
|
||||
const reviewContent = normalizeReviewText(
|
||||
source.reviewContent || source.content || source.text || source.review || source['评价内容']
|
||||
);
|
||||
if (!reviewContent) return null;
|
||||
return {
|
||||
reviewContent: useEmoji ? reviewContent : stripEmoji(reviewContent),
|
||||
userType: normalizeReviewText(source.userType || source.user || source.person || source['使用人']),
|
||||
sellingPoint: normalizeReviewText(source.sellingPoint || source.selling_points || source.point || source['突出卖点']),
|
||||
scene: normalizeReviewText(source.scene || source.usageScene || source['使用场景']),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviews(content, useEmoji) {
|
||||
const parsed = parseJsonContent(content, null);
|
||||
const source = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.reviews) ? parsed.reviews : []);
|
||||
if (source.length) return source.map((item) => normalizeReviewItem(item, useEmoji)).filter(Boolean);
|
||||
return String(content || '')
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/^\s*(\d+[.、)]|[-*])\s*/, ''))
|
||||
.filter((line) => line.trim().length >= 30)
|
||||
.map((line) => normalizeReviewItem(line, useEmoji))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function dedupeReviews(reviews) {
|
||||
const seen = new Set();
|
||||
return reviews.filter((review) => {
|
||||
const key = review.reviewContent.replace(/[,。!?,.!?\s]/g, '').slice(0, 80);
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function diversifyOpenings(reviews) {
|
||||
const counts = new Map();
|
||||
const replacements = {
|
||||
'说实话': ['其实,', '老实讲,', '用下来,'],
|
||||
'本来': ['一开始', '之前', '原先'],
|
||||
'我是': ['家里', '最近', '买回来后'],
|
||||
};
|
||||
return reviews.map((review) => {
|
||||
const opening = Object.keys(replacements).find((item) => review.reviewContent.startsWith(item));
|
||||
if (!opening) return review;
|
||||
const used = counts.get(opening) || 0;
|
||||
counts.set(opening, used + 1);
|
||||
if (used === 0 && opening !== '我是') return review;
|
||||
return {
|
||||
...review,
|
||||
reviewContent: review.reviewContent.replace(
|
||||
new RegExp(`^${opening}[,,、\s]*`),
|
||||
replacements[opening][used % replacements[opening].length]
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function generateReviewBatch({ count, batchIndex, scenes, userTypes, sellingPoints, analysis, useEmoji }) {
|
||||
const colors = normalizeMultiInput(analysis?.furnitureColors || analysis?.colors);
|
||||
const prompt = [
|
||||
'生成拼多多家具真实买家评价。',
|
||||
`1. 本批生成 ${count} 条。`,
|
||||
'2. 每条 50-200 个中文字符。',
|
||||
'3. 真实买家口吻,不要广告腔,不要出现“攻心”“卖点”“AI”。',
|
||||
'4. 角度要有差异,覆盖使用前顾虑、场景、到手体验、细节、家人反馈。',
|
||||
'5. 不要夸张承诺,不要医疗功效,不要绝对化表达。',
|
||||
'6. 场景和使用人自然交叉组合。',
|
||||
`7. 家具颜色只能使用这些图片识别颜色:${colors.length ? colors.join('、') : '未识别到颜色时不要主动描写颜色'}。`,
|
||||
'8. 文风口语化,像真实用户随手写的评价,不要整齐的产品说明书语气。',
|
||||
useEmoji
|
||||
? '9. 允许少量使用 emoji,每条最多 0-2 个,不要每条都必须有。'
|
||||
: '9. 严禁使用任何 emoji、颜文字或表情符号,一个都不要出现。',
|
||||
'10. 只返回 JSON 数组,每个元素字段固定:reviewContent、userType、sellingPoint、scene。',
|
||||
'11. 同一批开头必须分散,尽量不要使用“我是、本来、说实话、一开始、之前、之前还担心”开头。',
|
||||
`批次:${batchIndex}`,
|
||||
`可用场景:${scenes.length ? scenes.join('、') : analysis?.visualScene || '按图片判断'}`,
|
||||
`可用使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
|
||||
`运营填写卖点:${sellingPoints}`,
|
||||
`图片识别信息:${JSON.stringify(analysis || {})}`,
|
||||
].join('\n');
|
||||
const result = await callChatCompletion([{ role: 'user', content: prompt }], 'review', {
|
||||
maxTokens: Math.min(4096, 240 * count),
|
||||
});
|
||||
return { reviews: parseReviews(result.content, useEmoji), prompt, model: result.model };
|
||||
}
|
||||
|
||||
async function runWithLimit(tasks, limit, onResult) {
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < tasks.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
const result = await tasks[index]();
|
||||
onResult(result, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => worker()));
|
||||
}
|
||||
|
||||
async function generateHeartReviews({
|
||||
imageBuffer, mimeType, scene, userType, sellingPoints, useEmoji, reviewTotal, onEvent,
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const scenes = normalizeMultiInput(scene).filter((item) => SUPPORTED_SCENES.includes(item));
|
||||
const userTypes = normalizeMultiInput(userType).filter((item) => SUPPORTED_USER_TYPES.includes(item));
|
||||
const effectiveUserTypes = userTypes.length ? userTypes : ['自己购买给自己使用'];
|
||||
const normalizedSellingPoints = String(sellingPoints || '').trim();
|
||||
const targetTotal = REVIEW_TOTAL_OPTIONS.includes(Number(reviewTotal))
|
||||
? Number(reviewTotal)
|
||||
: DEFAULT_REVIEW_TOTAL;
|
||||
const normalizedUseEmoji = normalizeBoolean(useEmoji, true);
|
||||
if (!normalizedSellingPoints) {
|
||||
const error = new Error('请填写商品卖点');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const vision = await analyzeImage({
|
||||
imageBuffer, mimeType, scenes, userTypes: effectiveUserTypes, sellingPoints: normalizedSellingPoints,
|
||||
});
|
||||
onEvent?.({ type: 'analysis', imageAnalysis: vision.analysis });
|
||||
|
||||
let reviews = [];
|
||||
let reviewPrompt = '';
|
||||
let reviewModel = '';
|
||||
const append = (batch) => {
|
||||
if (!reviewPrompt) reviewPrompt = batch.prompt;
|
||||
if (!reviewModel) reviewModel = batch.model;
|
||||
reviews = dedupeReviews(diversifyOpenings([...reviews, ...batch.reviews])).slice(0, targetTotal);
|
||||
onEvent?.({ type: 'batch', total: reviews.length, targetTotal });
|
||||
};
|
||||
const makeTasks = (count, prefix) => Array.from({ length: Math.ceil(count / REVIEW_BATCH_SIZE) }, (_, index) => {
|
||||
const batchCount = Math.min(REVIEW_BATCH_SIZE, count - index * REVIEW_BATCH_SIZE);
|
||||
return () => generateReviewBatch({
|
||||
count: batchCount,
|
||||
batchIndex: `${prefix}${index + 1}`,
|
||||
scenes: scenes.length ? scenes : [vision.analysis?.visualScene].filter(Boolean),
|
||||
userTypes: effectiveUserTypes,
|
||||
sellingPoints: normalizedSellingPoints,
|
||||
analysis: vision.analysis,
|
||||
useEmoji: normalizedUseEmoji,
|
||||
});
|
||||
});
|
||||
|
||||
await runWithLimit(makeTasks(targetTotal, ''), REVIEW_MAX_CONCURRENCY, append);
|
||||
for (let attempt = 1; reviews.length < targetTotal && attempt <= REVIEW_MAX_REFILL_ATTEMPTS; attempt += 1) {
|
||||
await runWithLimit(makeTasks(targetTotal - reviews.length, `补充${attempt}-`), REVIEW_MAX_CONCURRENCY, append);
|
||||
}
|
||||
if (reviews.length < targetTotal) {
|
||||
const error = new Error(`AI返回评价数量不足,仅生成 ${reviews.length} 条,请重试`);
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
scene: scenes.length ? scenes.join('、') : (vision.analysis?.visualScene || 'AI识别场景'),
|
||||
scenes,
|
||||
userTypes: effectiveUserTypes,
|
||||
sellingPoints: normalizedSellingPoints,
|
||||
useEmoji: normalizedUseEmoji,
|
||||
imageAnalysis: vision.analysis,
|
||||
reviewPrompt,
|
||||
reviews: reviews.slice(0, targetTotal),
|
||||
total: targetTotal,
|
||||
targetTotal,
|
||||
responseMs: Date.now() - startedAt,
|
||||
models: { vision: vision.model, review: reviewModel },
|
||||
};
|
||||
}
|
||||
|
||||
function buildBuyerShowPrompt({ review, analysis, style = '真实' }) {
|
||||
const normalizedStyle = style === '饱和' ? '饱和' : '真实';
|
||||
const productHints = normalizeMultiInput([
|
||||
analysis?.productCategory,
|
||||
analysis?.productNameGuess,
|
||||
...(Array.isArray(analysis?.visualSellingPoints) ? analysis.visualSellingPoints : []),
|
||||
]).join('、');
|
||||
const styleRule = normalizedStyle === '饱和'
|
||||
? '画面可以更精致、饱满、明亮,接近小红书达人分享风格,但商品主体仍必须真实可信,不能变成棚拍硬广、样板间或3D渲染图。'
|
||||
: '普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光,不要商业摄影感、样板间、卖场图或渲染图。';
|
||||
return [
|
||||
`根据上传的商品参考图和下面这条买家评价,生成${normalizedStyle}风格的买家秀图片。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。`,
|
||||
`当前风格:${normalizedStyle}`,
|
||||
'硬性要求:必须以参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款、变形、新增或删减部件。商品参考图仅用于锁定商品本体,不允许沿用其房间、背景、软装和场景。',
|
||||
'家具结构数量限制:柜体、柜门、抽屉、层板、支脚、把手、开孔、轮子、拉篮和五金件数量、位置、比例必须与参考图一致。',
|
||||
'组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。禁止拼图、分屏、九宫格和多画面排版。',
|
||||
`买家评价:${review.reviewContent || ''}`,
|
||||
`使用人:${review.userType || '按评价自然判断'}`,
|
||||
`使用场景:${review.scene || '按评价自然判断'}`,
|
||||
`突出卖点:${review.sellingPoint || '按评价自然提炼'}`,
|
||||
productHints ? `图片识别到的商品信息:${productHints}` : '',
|
||||
`${styleRule} 如果上传了场景参考图,必须以它作为唯一主要场景依据,尽量保留房间类型、空间布局、墙面地面、窗户、非主体家具、软装、生活物品、光线氛围和模特姿态;只有冲突或遮挡商品主体的元素才可轻微调整。没有场景参考图时,才根据评价重新构建生活化场景。场景适合时最好需要有中国模特自然出镜,不能露脸,不能遮挡商品。输出 4:3,1K。`,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_REVIEW_TOTAL,
|
||||
REVIEW_TOTAL_OPTIONS,
|
||||
SUPPORTED_SCENES,
|
||||
SUPPORTED_USER_TYPES,
|
||||
buildBuyerShowPrompt,
|
||||
generateHeartReviews,
|
||||
};
|
||||
237
backend/src/services/aiBuyerShowSeedPackService.js
Normal file
237
backend/src/services/aiBuyerShowSeedPackService.js
Normal file
@ -0,0 +1,237 @@
|
||||
const crypto = require('crypto');
|
||||
const {
|
||||
getPool,
|
||||
query,
|
||||
resolveUser,
|
||||
} = require('./aiBuyerShowService');
|
||||
const { uploadBufferToDefaultS3 } = require('./objectStorage');
|
||||
|
||||
const SEED_PACK_TABLE = 'pdd_seed_resource_packs';
|
||||
const MAX_SEED_PACK_FILE_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
let tableReady = false;
|
||||
|
||||
function normalizeString(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeNullableString(value) {
|
||||
const text = normalizeString(value);
|
||||
return text || null;
|
||||
}
|
||||
|
||||
// multer/busboy may decode multipart filenames as latin1, garbling non-ASCII
|
||||
// names; re-encode to recover the original UTF-8 text.
|
||||
function normalizeUploadFileName(value) {
|
||||
const text = normalizeString(value);
|
||||
if (!text) return '';
|
||||
try {
|
||||
const decoded = Buffer.from(text, 'latin1').toString('utf8');
|
||||
return /[\uFFFD]/.test(decoded) ? text : decoded;
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSeedPackTable() {
|
||||
if (tableReady) return;
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS ${SEED_PACK_TABLE} (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID',
|
||||
pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识',
|
||||
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称',
|
||||
product_id VARCHAR(64) NOT NULL COMMENT '商品ID',
|
||||
sku_name VARCHAR(255) NULL COMMENT 'SKU名称',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名',
|
||||
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
|
||||
object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key',
|
||||
file_url VARCHAR(1024) NULL COMMENT '公开下载地址',
|
||||
review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数',
|
||||
operator_id VARCHAR(64) NULL COMMENT '上传人ID',
|
||||
operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人',
|
||||
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_pack_uid (pack_uid),
|
||||
KEY idx_seed_pack_shop (shop_name),
|
||||
KEY idx_seed_pack_product (product_id),
|
||||
KEY idx_seed_pack_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包'`
|
||||
);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
function normalizePackRow(row = {}) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
packUid: row.pack_uid ?? row.packUid ?? '',
|
||||
shopName: row.shop_name ?? row.shopName ?? '',
|
||||
productId: row.product_id ?? row.productId ?? '',
|
||||
skuName: row.sku_name ?? row.skuName ?? '',
|
||||
fileName: row.file_name ?? row.fileName ?? '',
|
||||
fileSize: Number(row.file_size ?? row.fileSize ?? 0),
|
||||
fileUrl: row.file_url ?? row.fileUrl ?? '',
|
||||
reviewCount: Number(row.review_count ?? row.reviewCount ?? 0),
|
||||
operatorName: row.operator_name ?? row.operatorName ?? '',
|
||||
downloadCount: Number(row.download_count ?? row.downloadCount ?? 0),
|
||||
createdAt: row.created_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSeedPackInput({ shopName, productId, file }) {
|
||||
if (!normalizeString(shopName)) {
|
||||
const error = new Error('店铺名称不能为空');
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
if (!normalizeString(productId)) {
|
||||
const error = new Error('商品ID不能为空');
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
if (!file?.buffer || !file.buffer.length) {
|
||||
const error = new Error('ZIP 文件不能为空');
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
if (file.buffer.length > MAX_SEED_PACK_FILE_BYTES) {
|
||||
const error = new Error('ZIP 文件不能超过 200MB');
|
||||
error.statusCode = 413;
|
||||
return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createSeedPack({ shopName, productId, skuName, fileName, reviewCount, file }, user = {}) {
|
||||
await ensureSeedPackTable();
|
||||
const operator = resolveUser(user);
|
||||
const validationError = validateSeedPackInput({ shopName, productId, file });
|
||||
if (validationError) throw validationError;
|
||||
|
||||
const uploaded = await uploadBufferToDefaultS3(
|
||||
file.buffer,
|
||||
'application/zip',
|
||||
'seed-packs',
|
||||
'zip'
|
||||
);
|
||||
|
||||
const resolvedFileName = normalizeString(fileName)
|
||||
|| normalizeUploadFileName(file?.originalname)
|
||||
|| `种菜资源包_${Date.now()}.zip`;
|
||||
|
||||
const packUid = crypto.randomUUID();
|
||||
const insertRows = await query(
|
||||
`INSERT INTO ${SEED_PACK_TABLE} (
|
||||
pack_uid, shop_name, product_id, sku_name, file_name, file_size,
|
||||
object_key, file_url, review_count, operator_id, operator_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
packUid,
|
||||
normalizeString(shopName).slice(0, 128),
|
||||
normalizeString(productId).slice(0, 64),
|
||||
normalizeNullableString(String(skuName || '').slice(0, 255)),
|
||||
resolvedFileName.slice(0, 255),
|
||||
file.buffer.length,
|
||||
uploaded.key,
|
||||
uploaded.url || '',
|
||||
Math.max(Number(reviewCount) || 0, 0),
|
||||
operator.id || null,
|
||||
operator.name,
|
||||
]
|
||||
);
|
||||
|
||||
const rows = await query(
|
||||
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
|
||||
[insertRows.insertId]
|
||||
);
|
||||
return normalizePackRow(rows[0]);
|
||||
}
|
||||
|
||||
async function listSeedPacks({ page = 1, pageSize = 15, keyword = '' } = {}) {
|
||||
await ensureSeedPackTable();
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1);
|
||||
const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100);
|
||||
const offset = (normalizedPage - 1) * normalizedPageSize;
|
||||
const normalizedKeyword = normalizeString(keyword);
|
||||
const likeKeyword = `%${normalizedKeyword}%`;
|
||||
const whereSql = normalizedKeyword
|
||||
? 'WHERE shop_name LIKE ? OR product_id LIKE ? OR sku_name LIKE ? OR operator_name LIKE ? OR file_name LIKE ?'
|
||||
: '';
|
||||
const params = normalizedKeyword ? Array(5).fill(likeKeyword) : [];
|
||||
|
||||
const countRows = await query(
|
||||
`SELECT COUNT(*) AS total FROM ${SEED_PACK_TABLE} ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const rows = await query(
|
||||
`SELECT * FROM ${SEED_PACK_TABLE}
|
||||
${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, normalizedPageSize, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
rows: rows.map(normalizePackRow),
|
||||
total: Number(countRows[0]?.total || 0),
|
||||
page: normalizedPage,
|
||||
pageSize: normalizedPageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSeedPackDownload(id) {
|
||||
await ensureSeedPackTable();
|
||||
const packId = Number(id);
|
||||
if (!Number.isFinite(packId) || packId <= 0) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const rows = await query(
|
||||
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
|
||||
[packId]
|
||||
);
|
||||
const pack = rows[0];
|
||||
if (!pack) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await query(
|
||||
`UPDATE ${SEED_PACK_TABLE} SET download_count = download_count + 1 WHERE id = ?`,
|
||||
[packId]
|
||||
);
|
||||
return {
|
||||
...normalizePackRow(pack),
|
||||
downloadCount: Number(pack.downloadCount || 0) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteSeedPack(id) {
|
||||
await ensureSeedPackTable();
|
||||
const packId = Number(id);
|
||||
if (!Number.isFinite(packId) || packId <= 0) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const rows = await query(
|
||||
`SELECT id FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
|
||||
[packId]
|
||||
);
|
||||
if (!rows.length) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await query(`DELETE FROM ${SEED_PACK_TABLE} WHERE id = ?`, [packId]);
|
||||
return { id: packId };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSeedPack,
|
||||
deleteSeedPack,
|
||||
getSeedPackDownload,
|
||||
listSeedPacks,
|
||||
MAX_SEED_PACK_FILE_BYTES,
|
||||
};
|
||||
1132
backend/src/services/aiBuyerShowService.js
Normal file
1132
backend/src/services/aiBuyerShowService.js
Normal file
File diff suppressed because it is too large
Load Diff
111
backend/src/services/csQualityAiPrompt.js
Normal file
111
backend/src/services/csQualityAiPrompt.js
Normal file
@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
const PRE_SALE_LABELS = [
|
||||
'库存备货:是否有货',
|
||||
'发货问题:发货地、发货速度、缺货问题、催发货、延期发货',
|
||||
'批量外贸单:贴唛头',
|
||||
'快递问题:是否包邮、指定快递、送货上门、物流速度、配送速度、到货时间',
|
||||
'产品问题:是否防潮/防火、是否有背板/轨道、材质的区别、产地问题、质保时效、尺寸问题、板材厚度、是否支持定制、承重性',
|
||||
'产品环保问题:检测报告、气味问题',
|
||||
'价格问题:优惠券问题、买后比价、商品降价',
|
||||
'下单错误:多拍错拍、地址/电话信息错误',
|
||||
'决策信息不充分:尺码/尺寸/规格不合适',
|
||||
'安装问题:安装说明书/安装视频、是否包安装、是否整装发货',
|
||||
'发票问题:是否有发票、多开/少开发票、催发票',
|
||||
];
|
||||
|
||||
const AFTER_SALE_LABELS = [
|
||||
'发货履约问题:发货异常、虚假发货',
|
||||
'物流配送问题:更新超时、丢件、派送超时、送货异常、退货到仓',
|
||||
'商品交付问题:少发漏发、错发、运输产品破损',
|
||||
'商品质量问题:产品质量、描述不符',
|
||||
'安装服务问题:安装咨询、上门安装',
|
||||
'客服服务问题:回复超时、服务态度极差、承诺未履行、操作错误',
|
||||
'退换货问题:上门取件',
|
||||
'平台业务问题:铺货错误、种菜异常',
|
||||
'其他售后:其他',
|
||||
];
|
||||
|
||||
function buildSystemPrompt() {
|
||||
return [
|
||||
'你是拼多多电商客服聊天质检与商品问题分析专家。',
|
||||
'你的任务是分析一个商品ID在指定日期范围内的全部客服聊天,识别售前和售后诉求,并把结果聚合成可用于产品改进、详情页优化和售后管理的结构化数据。',
|
||||
'',
|
||||
'【发言方识别】',
|
||||
'1. 每条消息已经提供“买家”或“客服”角色,优先使用该角色。',
|
||||
'2. 客服寒暄、安抚、礼貌语和标准话术不能单独作为证据;客服明确确认已超时、已登记、已拒绝、承诺未履行时可辅助举证。',
|
||||
'3. 归因以买家诉求为主,不能把客服建议或猜测当成买家问题。',
|
||||
'',
|
||||
'【售前/售后判定】',
|
||||
'1. 出现“订单编号:xxx”“订单号:xxx”或订单卡片后,从该位置开始的后续买家内容按售后语境处理;之前内容独立判断。',
|
||||
'2. 未出现订单号时,根据语义判断:下单前的购买决策咨询属于售前;已付款、已收货或明确反馈已发生问题属于售后。',
|
||||
'3. “多久发货”在下单前是售前;“为什么还不发货”或“催发货”通常是售后。',
|
||||
'4. 同一诉求无法区分时售后优先。否定、假设、举例、转述他人或已经解决的问题不能作为当前问题。',
|
||||
'',
|
||||
'【售前标签白名单】',
|
||||
...PRE_SALE_LABELS,
|
||||
'售前无法匹配时使用 primary_reason=“未知”、secondary_reason=“其他问题”。',
|
||||
'',
|
||||
'【售后标签白名单】',
|
||||
...AFTER_SALE_LABELS,
|
||||
'售后无法匹配时使用 primary_reason=“其他售后”、secondary_reason=“其他”。',
|
||||
'',
|
||||
'【标签边界】',
|
||||
'商品与页面描述、宣传或明确承诺不一致,使用“商品质量问题/描述不符”;商品本身缺陷、故障、开裂、变形,使用“商品质量问题/产品质量”;发错型号、款式、颜色或规格,使用“商品交付问题/错发”;缺少商品或配件,使用“商品交付问题/少发漏发”;运输造成磕碰、断裂或包装损坏,使用“商品交付问题/运输产品破损”。',
|
||||
'',
|
||||
'【聚合规则】',
|
||||
'1. 相同一级原因+二级原因的诉求合并为一条结果,统计 occurrence_count 和 affected_customer_count。',
|
||||
'2. 不同语义的独立问题分别输出,例如少发漏发和运输破损不能合并。',
|
||||
'3. 每条结果最多保留3条最有代表性的买家原话证据,证据不得改写或拼接。',
|
||||
'4. affected_conversation_ids 必须列出当前问题涉及的全部会话ID(uid),不得只列关键证据中的客户,也不得填入无关客户。',
|
||||
'5. 买家消息中的图片URL只提取与当前问题直接相关的图片,每条结果最多5张,必须原样保留。',
|
||||
'6. product_improvement_areas只能从:产品本体、规格配置、宣传主图、详情页、客服话术、价格策略、库存备货、履约物流、安装服务、售后服务、无需改进 中选择。',
|
||||
'',
|
||||
'【严格输出】',
|
||||
'只能输出合法JSON,不要输出Markdown代码块、解释或额外文字。JSON结构必须是:',
|
||||
JSON.stringify({
|
||||
schema_version: '1.0',
|
||||
conversation_type: '售前|售后|混合|无有效咨询',
|
||||
summary: {
|
||||
conversation_count: 0,
|
||||
pre_sale_conversation_count: 0,
|
||||
after_sale_conversation_count: 0,
|
||||
mixed_conversation_count: 0,
|
||||
main_findings: [],
|
||||
},
|
||||
results: [{
|
||||
consultation_type: '售前|售后',
|
||||
primary_reason: '一级原因',
|
||||
secondary_reason: '二级原因',
|
||||
occurrence_count: 1,
|
||||
affected_customer_count: 1,
|
||||
affected_conversation_ids: ['uid'],
|
||||
buyer_intent: '买家诉求概括',
|
||||
evidence: [{ conversation_id: 'uid', nickname: '买家昵称', message_time: '消息时间', text: '买家原文' }],
|
||||
image_urls: [],
|
||||
product_improvement_areas: [],
|
||||
improvement_suggestion: '',
|
||||
confidence: 0.95,
|
||||
}],
|
||||
}, null, 2),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildUserPrompt({ product = {}, metrics = {}, chatText = '', startDate, endDate }) {
|
||||
return [
|
||||
`请分析商品ID:${product.goodsId || ''}`,
|
||||
`商品名称:${product.goodsName || ''}`,
|
||||
`所属店铺:${product.shopName || ''}`,
|
||||
`图片地址:${product.hdThumbUrl || ''}`,
|
||||
`分析日期:${startDate} 至 ${endDate}`,
|
||||
`商品表现指标:${JSON.stringify(metrics)}`,
|
||||
'',
|
||||
'以下是该商品在指定日期范围内的聊天记录。每条记录已经标注角色,请结合上下文完成售前/售后识别、标签映射和问题聚合。',
|
||||
chatText,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
};
|
||||
1967
backend/src/services/csQualityService.js
Normal file
1967
backend/src/services/csQualityService.js
Normal file
File diff suppressed because it is too large
Load Diff
254
backend/src/services/desktopReleaseService.js
Normal file
254
backend/src/services/desktopReleaseService.js
Normal file
@ -0,0 +1,254 @@
|
||||
const database = require('../config/database');
|
||||
|
||||
const DESKTOP_RELEASE_CONFIG_KEY = 'desktop_client_releases';
|
||||
const MAX_RELEASE_HISTORY = 50;
|
||||
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
||||
const SUPPORTED_PLATFORMS = new Set(['win32', 'darwin']);
|
||||
const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64', 'universal']);
|
||||
|
||||
function normalizeVersion(value) {
|
||||
return String(value || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function versionParts(value) {
|
||||
const normalized = normalizeVersion(value);
|
||||
if (!VERSION_PATTERN.test(normalized)) return null;
|
||||
return normalized.split('.').map((part) => Number(part));
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const leftParts = versionParts(left);
|
||||
const rightParts = versionParts(right);
|
||||
if (!leftParts || !rightParts) return 0;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] > rightParts[index] ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function validateVersion(value) {
|
||||
const version = normalizeVersion(value);
|
||||
if (!VERSION_PATTERN.test(version)) {
|
||||
throw new Error('版本号格式必须为 x.y.z,例如 0.0.9');
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function normalizePlatform(value, fileName = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (['darwin', 'mac', 'macos', 'osx'].includes(normalized)) return 'darwin';
|
||||
if (['win32', 'win', 'windows'].includes(normalized)) return 'win32';
|
||||
if (String(fileName || '').toLowerCase().endsWith('.dmg')) return 'darwin';
|
||||
return 'win32';
|
||||
}
|
||||
|
||||
function normalizeArchitecture(value, fileName = '', platform = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (SUPPORTED_ARCHITECTURES.has(normalized)) return normalized;
|
||||
const fileMatch = String(fileName || '').toLowerCase().match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/);
|
||||
if (fileMatch) return fileMatch[1];
|
||||
return normalizePlatform(platform, fileName) === 'darwin' ? 'arm64' : 'x64';
|
||||
}
|
||||
|
||||
function normalizeStoredRelease(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const version = normalizeVersion(value.version);
|
||||
if (!VERSION_PATTERN.test(version) || !value.objectKey) return null;
|
||||
const platform = normalizePlatform(value.platform, value.fileName || value.objectKey);
|
||||
const architecture = normalizeArchitecture(value.architecture, value.fileName || value.objectKey, platform);
|
||||
const defaultFileName = platform === 'darwin'
|
||||
? `pdd-auto-sale-${version}-${architecture}.dmg`
|
||||
: `pdd-auto-sale-${version}-setup.exe`;
|
||||
return {
|
||||
id: Number(value.id) || Date.now(),
|
||||
version,
|
||||
releaseNotes: String(value.releaseNotes || ''),
|
||||
objectKey: String(value.objectKey),
|
||||
platform,
|
||||
architecture,
|
||||
fileName: String(value.fileName || defaultFileName),
|
||||
fileSize: Number(value.fileSize || 0),
|
||||
sha256: String(value.sha256 || ''),
|
||||
publishedByUserId: value.publishedByUserId == null ? null : Number(value.publishedByUserId),
|
||||
publishedByName: String(value.publishedByName || ''),
|
||||
publishedAt: value.publishedAt || value.createdAt || null,
|
||||
createdAt: value.createdAt || value.publishedAt || null,
|
||||
updatedAt: value.updatedAt || value.publishedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseReleaseStorage(value) {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value || '{}') : value;
|
||||
const releases = Array.isArray(parsed?.releases)
|
||||
? parsed.releases.map(normalizeStoredRelease).filter(Boolean)
|
||||
: [];
|
||||
const storedLatestVersions = parsed?.latestVersions && typeof parsed.latestVersions === 'object'
|
||||
? parsed.latestVersions
|
||||
: {};
|
||||
const latestVersions = {};
|
||||
for (const platform of SUPPORTED_PLATFORMS) {
|
||||
const version = normalizeVersion(storedLatestVersions[platform]);
|
||||
if (VERSION_PATTERN.test(version)) latestVersions[platform] = version;
|
||||
}
|
||||
const legacyLatestVersion = normalizeVersion(parsed?.latestVersion);
|
||||
if (!latestVersions.win32 && VERSION_PATTERN.test(legacyLatestVersion)) {
|
||||
latestVersions.win32 = legacyLatestVersion;
|
||||
}
|
||||
for (const release of releases) {
|
||||
if (!latestVersions[release.platform]) latestVersions[release.platform] = release.version;
|
||||
}
|
||||
return {
|
||||
latestVersion: latestVersions.win32 || legacyLatestVersion,
|
||||
latestVersions,
|
||||
releases,
|
||||
};
|
||||
} catch {
|
||||
return { latestVersion: '', latestVersions: {}, releases: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReleaseStorage() {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[DESKTOP_RELEASE_CONFIG_KEY]
|
||||
);
|
||||
return parseReleaseStorage(rows[0]?.config_value);
|
||||
}
|
||||
|
||||
async function saveReleaseStorage(storage) {
|
||||
const value = JSON.stringify(storage);
|
||||
const existing = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[DESKTOP_RELEASE_CONFIG_KEY]
|
||||
);
|
||||
if (existing.length) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[value, 'json', DESKTOP_RELEASE_CONFIG_KEY]
|
||||
);
|
||||
return;
|
||||
}
|
||||
await database.local.insert('system_config', {
|
||||
config_key: DESKTOP_RELEASE_CONFIG_KEY,
|
||||
config_value: value,
|
||||
config_type: 'json',
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureDesktopReleaseSchema() {
|
||||
// Release metadata uses system_config because the runtime account cannot create tables.
|
||||
}
|
||||
|
||||
function mapRelease(release, latestVersion = '') {
|
||||
const normalized = normalizeStoredRelease(release);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
...normalized,
|
||||
isLatest: normalized.version === normalizeVersion(latestVersion),
|
||||
};
|
||||
}
|
||||
|
||||
async function getLatestDesktopRelease(platform = 'win32', architecture = '') {
|
||||
const storage = await loadReleaseStorage();
|
||||
const targetPlatform = normalizePlatform(platform);
|
||||
const targetArchitecture = architecture
|
||||
? normalizeArchitecture(architecture, '', targetPlatform)
|
||||
: '';
|
||||
const platformReleases = storage.releases
|
||||
.filter((release) => release.platform === targetPlatform)
|
||||
.sort((left, right) => (
|
||||
compareVersions(right.version, left.version)
|
||||
|| String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''))
|
||||
));
|
||||
const latestVersion = storage.latestVersions[targetPlatform] || platformReleases[0]?.version || '';
|
||||
const versionReleases = platformReleases.filter((release) => release.version === latestVersion);
|
||||
const latest = (targetArchitecture
|
||||
? versionReleases.find((release) => release.architecture === targetArchitecture)
|
||||
|| versionReleases.find((release) => release.architecture === 'universal')
|
||||
: null)
|
||||
|| versionReleases[0]
|
||||
|| platformReleases[0]
|
||||
|| null;
|
||||
return latest ? mapRelease(latest, latestVersion || latest.version) : null;
|
||||
}
|
||||
|
||||
async function getDesktopReleaseById(id) {
|
||||
const storage = await loadReleaseStorage();
|
||||
const release = storage.releases.find((item) => String(item.id) === String(id));
|
||||
return release ? mapRelease(release, storage.latestVersions[release.platform]) : null;
|
||||
}
|
||||
|
||||
async function listDesktopReleases(limit = 50) {
|
||||
const storage = await loadReleaseStorage();
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100);
|
||||
return storage.releases
|
||||
.map((release) => mapRelease(release, storage.latestVersions[release.platform]))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
if (left.isLatest !== right.isLatest) return left.isLatest ? -1 : 1;
|
||||
return String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''));
|
||||
})
|
||||
.slice(0, safeLimit);
|
||||
}
|
||||
|
||||
async function publishDesktopRelease(input) {
|
||||
const version = validateVersion(input.version);
|
||||
const storage = await loadReleaseStorage();
|
||||
const platform = normalizePlatform(input.platform, input.fileName || input.objectKey);
|
||||
const architecture = normalizeArchitecture(input.architecture, input.fileName || input.objectKey, platform);
|
||||
const existing = storage.releases.find((release) => (
|
||||
release.version === version
|
||||
&& release.platform === platform
|
||||
&& release.architecture === architecture
|
||||
));
|
||||
const now = new Date().toISOString();
|
||||
const release = {
|
||||
id: existing?.id || Date.now(),
|
||||
version,
|
||||
platform,
|
||||
architecture,
|
||||
releaseNotes: input.releaseNotes || '',
|
||||
objectKey: input.objectKey,
|
||||
fileName: input.fileName,
|
||||
fileSize: Number(input.fileSize || 0),
|
||||
sha256: input.sha256,
|
||||
publishedByUserId: input.publishedByUserId || null,
|
||||
publishedByName: input.publishedByName || '',
|
||||
publishedAt: now,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const releases = [
|
||||
release,
|
||||
...storage.releases.filter((item) => !(
|
||||
item.version === version
|
||||
&& item.platform === platform
|
||||
&& item.architecture === architecture
|
||||
)),
|
||||
].slice(0, MAX_RELEASE_HISTORY);
|
||||
const latestVersions = { ...storage.latestVersions, [platform]: version };
|
||||
await saveReleaseStorage({
|
||||
latestVersion: latestVersions.win32 || version,
|
||||
latestVersions,
|
||||
releases,
|
||||
});
|
||||
return mapRelease(release, version);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareVersions,
|
||||
ensureDesktopReleaseSchema,
|
||||
getDesktopReleaseById,
|
||||
getLatestDesktopRelease,
|
||||
listDesktopReleases,
|
||||
mapRelease,
|
||||
normalizeVersion,
|
||||
normalizeArchitecture,
|
||||
normalizePlatform,
|
||||
parseReleaseStorage,
|
||||
publishDesktopRelease,
|
||||
validateVersion,
|
||||
};
|
||||
@ -66,7 +66,7 @@ function buildDesktopCapabilities(user) {
|
||||
const allowed = new Set(Array.isArray(user?.allowedPageKeys) ? user.allowedPageKeys : []);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const canPlan = isAdmin || allowed.has('planning_upload');
|
||||
const canUseMarket = isAdmin || canPlan;
|
||||
const canUseMarket = isAdmin || allowed.has('market_insight') || canPlan;
|
||||
|
||||
return {
|
||||
controlCenter: true,
|
||||
@ -77,7 +77,10 @@ function buildDesktopCapabilities(user) {
|
||||
shopManagement: canPlan,
|
||||
execution: canPlan,
|
||||
logs: true,
|
||||
marketingEvents: isAdmin || allowed.has('marketing_events'),
|
||||
promotion: isAdmin || allowed.has('promotion_module'),
|
||||
productTest: isAdmin || allowed.has('new_product_test'),
|
||||
reviewAnalysis: isAdmin || allowed.has('review_analysis'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -3,10 +3,16 @@ const { cleanupRetiredFeatureSettings } = require('./retiredFeatures');
|
||||
const { ensureDepartmentPagePermissions, ensureSidebarRolePermissions } = require('./pageAccess');
|
||||
const { repairDuplicateExternalDepartments } = require('./socialAuth');
|
||||
const { ensureSetupConfigDefaults } = require('./setupWizard');
|
||||
const { ensureDesktopReleaseSchema } = require('./desktopReleaseService');
|
||||
const { ensureDesktopPresenceSchema } = require('./desktopPresenceService');
|
||||
const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService');
|
||||
const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService');
|
||||
const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService');
|
||||
const { ensureProductTagConfigSchema } = require('./productTagConfigService');
|
||||
const {
|
||||
ensureProductMonitoringSchema,
|
||||
warmProductMonitoringDataset,
|
||||
} = require('./productMonitoringService');
|
||||
|
||||
const USER_COLUMNS = [
|
||||
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
|
||||
@ -238,10 +244,47 @@ async function seedRedisConfigFromEnv() {
|
||||
}
|
||||
|
||||
async function ensureLocalSchema() {
|
||||
await ensureTable(
|
||||
'ops_b_incentive_snapshots',
|
||||
`CREATE TABLE IF NOT EXISTS ops_b_incentive_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
snapshot_key VARCHAR(80) NOT NULL,
|
||||
scope_start DATE NOT NULL,
|
||||
scope_end DATE NOT NULL,
|
||||
metric_end DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||
finalized TINYINT(1) NOT NULL DEFAULT 0,
|
||||
rows_json MEDIUMTEXT NOT NULL,
|
||||
summary_json TEXT NULL,
|
||||
generated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ops_b_snapshot_key (snapshot_key),
|
||||
KEY idx_ops_b_snapshot_period (scope_start, scope_end, metric_end, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
);
|
||||
await ensureTable(
|
||||
'ops_b_incentive_periods',
|
||||
`CREATE TABLE IF NOT EXISTS ops_b_incentive_periods (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scope_start DATE NOT NULL COMMENT '上架日期区间起',
|
||||
scope_end DATE NOT NULL COMMENT '上架日期区间止',
|
||||
pull_date DATE NOT NULL COMMENT '拉取(冻结)日期',
|
||||
fee_cap DECIMAL(5,4) NOT NULL COMMENT '费比达标上限',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
);
|
||||
await ensureDesktopReleaseSchema();
|
||||
await ensureDesktopPresenceSchema();
|
||||
await ensureDesktopExecutionTaskSchema();
|
||||
await ensureThirdPartySsoSchema();
|
||||
await ensureThirdPartySsoDirectSchema();
|
||||
await ensureProductTagConfigSchema();
|
||||
|
||||
await ensureTable(
|
||||
'auth_configs',
|
||||
@ -877,7 +920,11 @@ async function ensureLocalSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'`
|
||||
);
|
||||
|
||||
await ensureProductMonitoringSchema();
|
||||
|
||||
await ensureSidebarRolePermissions();
|
||||
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
||||
void warmProductMonitoringDataset();
|
||||
await ensureDepartmentPagePermissions();
|
||||
await cleanupRetiredFeatureSettings(database.local);
|
||||
await repairDuplicateExternalDepartments();
|
||||
|
||||
430
backend/src/services/opsBIncentiveBoardService.js
Normal file
430
backend/src/services/opsBIncentiveBoardService.js
Normal file
@ -0,0 +1,430 @@
|
||||
const dayjs = require('dayjs');
|
||||
const database = require('../config/database');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
|
||||
const DEFAULT_SCOPE_START = '2026-07-15';
|
||||
const DEFAULT_SCOPE_END = '2026-07-21';
|
||||
const DEFAULT_END_DATE = '2026-09-02';
|
||||
const AWARD_PER_ID = 400;
|
||||
const JULY_START = '2026-07-01';
|
||||
const JULY_END = '2026-07-31';
|
||||
|
||||
function text(value, fallback = '') {
|
||||
const valueText = String(value ?? '').trim();
|
||||
return valueText || fallback;
|
||||
}
|
||||
|
||||
function validDate(value, fallback) {
|
||||
const valueText = text(value, fallback);
|
||||
return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback;
|
||||
}
|
||||
|
||||
function addDays(value, offset) {
|
||||
return dayjs(value).add(offset, 'day').format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function resolveQuery(query = {}) {
|
||||
const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START);
|
||||
const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END);
|
||||
const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE);
|
||||
const metricStart = addDays(endDate, -29);
|
||||
const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9;
|
||||
return {
|
||||
scopeStart,
|
||||
scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd,
|
||||
metricStart,
|
||||
metricEnd: endDate,
|
||||
feeCap,
|
||||
ider: text(query.ider),
|
||||
shopName: text(query.shopName),
|
||||
spu: text(query.spu),
|
||||
goodsId: text(query.goodsId),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeRow(row = {}, query) {
|
||||
const shippedDays30d = Number(row.shippedDays30d || 0);
|
||||
const performance30d = normalizeNumber(row.performance30d);
|
||||
const fee30d = normalizeNumber(row.fee30d);
|
||||
const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null;
|
||||
const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null;
|
||||
const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||
? dayjs(firstCreatedAt).startOf('day')
|
||||
: null;
|
||||
const metricEndDay = dayjs(query.metricEnd).startOf('day');
|
||||
const listedCalendarDays = listingStart && metricEndDay.isValid()
|
||||
? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1)
|
||||
: 30;
|
||||
const metricDenominatorDays = Math.min(30, listedCalendarDays);
|
||||
const sellThroughRate30d = shippedDays30d / metricDenominatorDays;
|
||||
const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap;
|
||||
const bonusQualified = feeQualified && sellThroughRate30d > 0.3;
|
||||
const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||
? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day'))
|
||||
: null;
|
||||
return {
|
||||
...row,
|
||||
goodsId: text(row.goodsId),
|
||||
ider: text(row.ider),
|
||||
shopName: text(row.shopName),
|
||||
spu: text(row.spu),
|
||||
goodsName: text(row.goodsName),
|
||||
linkType: text(row.linkType),
|
||||
status: Number(row.onsale || 0) === 1 ? '在售' : '已下架',
|
||||
skuCount: Number(row.skuCount || 0),
|
||||
listingDays,
|
||||
julyPerformance: normalizeNumber(row.julyPerformance),
|
||||
julyFee: normalizeNumber(row.julyFee),
|
||||
julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null,
|
||||
julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0,
|
||||
julyShippedDays: Number(row.julyShippedDays || 0),
|
||||
julySellThroughRate: Number(row.julyShippedDays || 0) / 31,
|
||||
performance30d,
|
||||
fee30d,
|
||||
feeRatio30d,
|
||||
goodsCount30d: normalizeNumber(row.goodsCount30d) || 0,
|
||||
shippedDays30d,
|
||||
metricDenominatorDays,
|
||||
sellThroughRate30d,
|
||||
feeQualified,
|
||||
sellThroughQualified: sellThroughRate30d > 0.3,
|
||||
bonusQualified,
|
||||
bonus: bonusQualified ? AWARD_PER_ID : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildParams(query) {
|
||||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||
return {
|
||||
scopeStart: query.scopeStart,
|
||||
scopeEnd: query.scopeEnd,
|
||||
metricStart: query.metricStart,
|
||||
metricEnd: query.metricEnd,
|
||||
julyStart: JULY_START,
|
||||
julyEnd: JULY_END,
|
||||
ider: query.ider,
|
||||
shopName: query.shopName,
|
||||
spu: query.spu,
|
||||
goodsId: query.goodsId,
|
||||
scopeStartEpochSec: epoch(query.scopeStart),
|
||||
scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)),
|
||||
scopeStartEpochMs: epoch(query.scopeStart) * 1000,
|
||||
scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyMetrics() {
|
||||
return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 };
|
||||
}
|
||||
|
||||
async function loadSalesMetrics(goodsIds, query) {
|
||||
const metrics = new Map();
|
||||
const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))];
|
||||
for (let offset = 0; offset < ids.length; offset += 1000) {
|
||||
const chunk = ids.slice(offset, offset + 1000);
|
||||
const inList = chunk.map((id) => `'${id}'`).join(',');
|
||||
const rows = await cloudDatabase.query(`
|
||||
SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount,
|
||||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d,
|
||||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d
|
||||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||
WHERE d.item_id IN (${inList})
|
||||
AND d.stat_date BETWEEN @julyStart AND @metricEnd
|
||||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id)))
|
||||
`, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 });
|
||||
rows.forEach((row) => metrics.set(String(row.goodsId), row));
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。
|
||||
// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。
|
||||
async function pickSkuCaptureTime(upperBound) {
|
||||
const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD');
|
||||
const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD'));
|
||||
const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query(
|
||||
'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime',
|
||||
{ captureTime }, { requestTimeout: 60000 }
|
||||
)));
|
||||
const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) }));
|
||||
const maxCount = Math.max(...mapped.map((item) => item.goodsCount));
|
||||
if (maxCount <= 0) return null;
|
||||
return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date;
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBoardLive(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const livePeriod = findPeriod(await loadPeriods(), query);
|
||||
if (livePeriod) query.feeCap = livePeriod.feeCap;
|
||||
const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 });
|
||||
const latestCreateTime = await pickSkuCaptureTime(query.metricEnd);
|
||||
if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次');
|
||||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||
const startSec = epoch(query.scopeStart);
|
||||
const endSec = epoch(addDays(query.scopeEnd, 1));
|
||||
const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 });
|
||||
const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row]));
|
||||
const scopeRows = skuRows.flatMap((row) => {
|
||||
const target = targetMap.get(String(row.goodsId));
|
||||
if (!target) return [];
|
||||
if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return [];
|
||||
return [{ ...row, ...target }];
|
||||
});
|
||||
const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query);
|
||||
const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) }));
|
||||
let dwsLatestRows = [];
|
||||
try {
|
||||
dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 });
|
||||
} catch (error) {
|
||||
console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message);
|
||||
}
|
||||
const normalizedRows = (rows || []).map((row) => normalizeRow(row, query));
|
||||
const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified);
|
||||
const ownerMap = new Map();
|
||||
normalizedRows.forEach((row) => {
|
||||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||
current.goodsIdCount += 1;
|
||||
current.goodsCount30d += row.goodsCount30d;
|
||||
current.shippedDays30d += row.shippedDays30d;
|
||||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||
current.bonus += row.bonus;
|
||||
ownerMap.set(row.ider, current);
|
||||
});
|
||||
return {
|
||||
rows: normalizedRows,
|
||||
bonusRows: qualifiedRows,
|
||||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||
summary: {
|
||||
goodsIdCount: normalizedRows.length,
|
||||
feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length,
|
||||
sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length,
|
||||
bonusQualifiedCount: qualifiedRows.length,
|
||||
bonus: qualifiedRows.length * AWARD_PER_ID,
|
||||
awardPerId: AWARD_PER_ID,
|
||||
},
|
||||
query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null },
|
||||
};
|
||||
}
|
||||
|
||||
// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。
|
||||
// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。
|
||||
const DEFAULT_SNAPSHOT_PERIODS = [
|
||||
{ scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 },
|
||||
{ scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 },
|
||||
{ scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 },
|
||||
{ scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 },
|
||||
{ scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 },
|
||||
{ scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 },
|
||||
{ scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 },
|
||||
{ scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 },
|
||||
{ scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 },
|
||||
{ scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 },
|
||||
];
|
||||
|
||||
function makePeriodKey(scopeStart, scopeEnd) {
|
||||
return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`;
|
||||
}
|
||||
|
||||
let periodsCache = null;
|
||||
let periodsCacheExpiresAt = 0;
|
||||
|
||||
async function loadPeriods() {
|
||||
if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache;
|
||||
let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||
if (!rows.length) {
|
||||
for (const period of DEFAULT_SNAPSHOT_PERIODS) {
|
||||
await database.local.query(
|
||||
'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)',
|
||||
[period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active']
|
||||
);
|
||||
}
|
||||
rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||
}
|
||||
periodsCache = rows
|
||||
.filter((row) => String(row.status || 'active') === 'active')
|
||||
.map((row) => ({
|
||||
key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')),
|
||||
scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'),
|
||||
scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'),
|
||||
pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'),
|
||||
feeCap: Number(row.fee_cap),
|
||||
}));
|
||||
periodsCacheExpiresAt = Date.now() + 60000;
|
||||
return periodsCache;
|
||||
}
|
||||
|
||||
function findPeriod(periods, query) {
|
||||
return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null;
|
||||
}
|
||||
|
||||
async function readSnapshot(period) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1',
|
||||
[period.key, 'completed']
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
try {
|
||||
const meta = JSON.parse(row.summary_json || '{}');
|
||||
return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at };
|
||||
} catch (error) {
|
||||
console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(period, query, result, finalized) {
|
||||
await database.local.query(`
|
||||
INSERT INTO ops_b_incentive_snapshots
|
||||
(snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message)
|
||||
VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json),
|
||||
generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL
|
||||
`, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]);
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBoard(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const period = findPeriod(await loadPeriods(), query);
|
||||
if (period) query.feeCap = period.feeCap;
|
||||
if (!period) return getOpsBIncentiveBoardLive(input);
|
||||
const existing = await readSnapshot(period);
|
||||
if (!existing) {
|
||||
return {
|
||||
pending: true,
|
||||
rows: [],
|
||||
bonusRows: [],
|
||||
ownerSummary: [],
|
||||
summary: {},
|
||||
query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } },
|
||||
};
|
||||
}
|
||||
const filteredRows = existing.rows.filter((row) => (
|
||||
(!query.ider || row.ider === query.ider)
|
||||
&& (!query.shopName || row.shopName === query.shopName)
|
||||
&& (!query.spu || String(row.spu || '').includes(query.spu))
|
||||
&& (!query.goodsId || String(row.goodsId || '').includes(query.goodsId))
|
||||
));
|
||||
const ownerMap = new Map();
|
||||
filteredRows.forEach((row) => {
|
||||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||
current.goodsIdCount += 1;
|
||||
current.goodsCount30d += Number(row.goodsCount30d || 0);
|
||||
current.shippedDays30d += Number(row.shippedDays30d || 0);
|
||||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||
current.bonus += Number(row.bonus || 0);
|
||||
ownerMap.set(row.ider, current);
|
||||
});
|
||||
const bonusRows = filteredRows.filter((row) => row.bonusQualified);
|
||||
return {
|
||||
...existing,
|
||||
rows: filteredRows,
|
||||
bonusRows,
|
||||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||
summary: {
|
||||
goodsIdCount: filteredRows.length,
|
||||
feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length,
|
||||
sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length,
|
||||
bonusQualifiedCount: bonusRows.length,
|
||||
bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0),
|
||||
awardPerId: AWARD_PER_ID,
|
||||
},
|
||||
query: {
|
||||
...query,
|
||||
metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'),
|
||||
latestDwsDate: null,
|
||||
snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshOpsBIncentiveSnapshots(source = 'scheduled') {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const results = [];
|
||||
for (const period of await loadPeriods()) {
|
||||
const { scopeStart, scopeEnd } = period;
|
||||
// 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。
|
||||
const finalized = today > period.pullDate;
|
||||
const existing = await readSnapshot(period);
|
||||
if (existing?.finalized) {
|
||||
results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source });
|
||||
continue;
|
||||
}
|
||||
const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate });
|
||||
await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized);
|
||||
results.push({ key: period.key, rows: result.rows.length, finalized, source });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBatches() {
|
||||
const periods = await loadPeriods();
|
||||
const snapshots = await database.local.query(
|
||||
"SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'"
|
||||
);
|
||||
const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row]));
|
||||
return periods.map((period, index) => {
|
||||
const snapshot = snapshotMap.get(period.key) || {};
|
||||
return {
|
||||
...period,
|
||||
batchNo: index + 1,
|
||||
finalized: Boolean(snapshot.finalized),
|
||||
generatedAt: snapshot.generated_at || null,
|
||||
goodsIdCount: Number(snapshot.goodsIdCount || 0),
|
||||
bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0),
|
||||
bonus: Number(snapshot.bonus || 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveDaily(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const params = buildParams(query);
|
||||
if (!query.goodsId) return { rows: [], query };
|
||||
const rows = await cloudDatabase.query(`
|
||||
WITH daily AS (
|
||||
SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee
|
||||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId
|
||||
AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd
|
||||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||
GROUP BY TRY_CONVERT(DATE, d.stat_date)
|
||||
) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee
|
||||
FROM daily ORDER BY statDate ASC
|
||||
`, params, { requestTimeout: 120000 });
|
||||
return { rows: rows || [], query };
|
||||
}
|
||||
|
||||
module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS };
|
||||
943
backend/src/services/productMonitoringService.js
Normal file
943
backend/src/services/productMonitoringService.js
Normal file
@ -0,0 +1,943 @@
|
||||
'use strict';
|
||||
|
||||
// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标
|
||||
//
|
||||
// 数据来源(2026-09-11 实测确认):
|
||||
// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源
|
||||
// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、
|
||||
// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus)
|
||||
// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸
|
||||
// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings
|
||||
// (公司 SABC,由「系统设置 → 商品标签配置」维护)
|
||||
// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C)
|
||||
// 配置 系统库 department_sku_owners (部门 SKU 负责人)
|
||||
//
|
||||
// 判定口径:
|
||||
// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中)
|
||||
// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停
|
||||
//
|
||||
// ⚠️ 已知留空字段(等业务给源表后再填):
|
||||
// - 在途库存 inTransitStock → null,前端显示 `--`
|
||||
// - 部门未发货数量 departmentUnshipped → null,前端显示 `--`
|
||||
// (pm_stockMonitor.outOfStock 语义未确认,本次不用)
|
||||
|
||||
const database = require('../config/database');
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const productTagConfigService = require('./productTagConfigService');
|
||||
|
||||
const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor';
|
||||
// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营
|
||||
const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di';
|
||||
const SALES_WINDOW_DAYS = 7;
|
||||
// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级
|
||||
// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale
|
||||
const SALES_PERFORMANCE_WINDOW_DAYS = 30;
|
||||
|
||||
const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']);
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
const DEPARTMENT_TAG_SCOPE = 'department';
|
||||
const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']);
|
||||
const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']);
|
||||
|
||||
// ==================== 表结构 ====================
|
||||
|
||||
async function ensureTable(tableName, createSql) {
|
||||
await database.local.query(createSql);
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`Table ${tableName} create failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProductMonitoringSchema() {
|
||||
await ensureTable(
|
||||
'product_tag_rules',
|
||||
`CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C',
|
||||
metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d',
|
||||
threshold_min DECIMAL(18,4) NULL,
|
||||
threshold_max DECIMAL(18,4) NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||
KEY idx_scope_enabled (tag_scope, enabled),
|
||||
KEY idx_metric (metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'department_sku_owners',
|
||||
`CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
department_id BIGINT UNSIGNED NOT NULL,
|
||||
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||
KEY idx_dept (department_id),
|
||||
KEY idx_spu (spu),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'`
|
||||
);
|
||||
|
||||
// 部门标签默认四档(不存在时播种,口径:近 7 天销售件数)
|
||||
const existing = await database.local.query(
|
||||
'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?',
|
||||
[DEPARTMENT_TAG_SCOPE]
|
||||
);
|
||||
if (Number(existing[0]?.c || 0) === 0) {
|
||||
const seeds = [
|
||||
['G-S', 500, null, 1],
|
||||
['G-A', 200, 500, 2],
|
||||
['G-B', 50, 200, 3],
|
||||
['G-C', 0, 50, 4],
|
||||
];
|
||||
for (const [level, min, max, priority] of seeds) {
|
||||
await database.local.query(
|
||||
`INSERT INTO product_tag_rules
|
||||
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE tag_scope = tag_scope`,
|
||||
[DEPARTMENT_TAG_SCOPE, level, min, max, priority]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 配置读取 ====================
|
||||
|
||||
async function loadDepartmentTagRules() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled
|
||||
FROM product_tag_rules
|
||||
WHERE tag_scope = ? AND enabled = 1
|
||||
ORDER BY priority ASC, id ASC`,
|
||||
[DEPARTMENT_TAG_SCOPE]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
tagLevel: row.tag_level,
|
||||
metricKey: row.metric_key,
|
||||
thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min),
|
||||
thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max),
|
||||
priority: Number(row.priority || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度)
|
||||
async function loadCompanyOwnerIndex() {
|
||||
try {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''`
|
||||
);
|
||||
const index = new Map();
|
||||
rows.forEach((row) => {
|
||||
const spu = String(row.spu || '').trim();
|
||||
const oper = String(row.oper || '').trim();
|
||||
if (spu && oper && !index.has(spu)) index.set(spu, oper);
|
||||
});
|
||||
return index;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartmentSkuOwners() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name
|
||||
FROM department_sku_owners o
|
||||
LEFT JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN departments d ON d.id = o.department_id`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
departmentId: row.department_id,
|
||||
departmentName: row.department_name,
|
||||
ownerType: row.owner_type,
|
||||
userId: row.user_id,
|
||||
ownerName: row.user_name,
|
||||
spu: row.spu,
|
||||
}));
|
||||
}
|
||||
|
||||
// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null
|
||||
function matchDepartmentTag(rules, salesQty7d) {
|
||||
if (!rules.length) return null;
|
||||
// ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档
|
||||
if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null;
|
||||
const value = Number(salesQty7d);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
for (const rule of rules) {
|
||||
const geMin = rule.thresholdMin === null || value >= rule.thresholdMin;
|
||||
const ltMax = rule.thresholdMax === null || value < rule.thresholdMax;
|
||||
if (geMin && ltMax) return rule.tagLevel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 业务库查询 ====================
|
||||
|
||||
// 可排序字段(排序在内存完成,直接用行字段名)
|
||||
const DERIVED_SORT_FIELDS = new Set([
|
||||
'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus',
|
||||
'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish',
|
||||
'companyTag', 'department', 'departmentTag', 'departmentOwner',
|
||||
'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator',
|
||||
]);
|
||||
|
||||
async function getLatestReportDate() {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}`
|
||||
);
|
||||
const latest = rows[0]?.latestDate;
|
||||
if (!latest) {
|
||||
throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期');
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸
|
||||
//
|
||||
// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存,
|
||||
// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。
|
||||
async function queryAllReportRows(latestDate) {
|
||||
const params = { latestDate };
|
||||
|
||||
const dataSql = `
|
||||
SELECT
|
||||
m.sku_id AS skuId,
|
||||
m.sku AS skuCode,
|
||||
m.spu AS spu,
|
||||
m.skuName AS productName,
|
||||
m.category AS category,
|
||||
m.supplier AS supplier,
|
||||
m.warehouse AS warehouse,
|
||||
m.purchase AS purchaser,
|
||||
m.area AS area,
|
||||
m.cooperation AS cooperation,
|
||||
m.brand AS brand,
|
||||
m.sku_idStatus AS saleStatus,
|
||||
m.jsStock AS availableStock,
|
||||
m.jsUseDays AS stockDays,
|
||||
m.sevenAvgSale AS companySales7d,
|
||||
m.suggestStock AS suggestedReplenish,
|
||||
m.produceCycle AS produceCycle,
|
||||
m.safetyDays AS safetyDays,
|
||||
s.sku_code AS barcode,
|
||||
s.color AS colorRaw,
|
||||
s.properties_value AS sizeRaw,
|
||||
COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material,
|
||||
COALESCE(s.pic_big, s.pic) AS imageUrl
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id
|
||||
WHERE m.date = @latestDate
|
||||
`;
|
||||
|
||||
const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 });
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => ({
|
||||
skuId: row.skuId,
|
||||
skuCode: row.skuCode || row.skuId,
|
||||
spu: row.spu,
|
||||
barcode: row.barcode || row.skuId,
|
||||
imageUrl: row.imageUrl || null,
|
||||
category: row.category,
|
||||
productName: row.productName,
|
||||
material: row.material || null,
|
||||
color: row.colorRaw || null,
|
||||
size: row.sizeRaw || null,
|
||||
saleStatus: row.saleStatus,
|
||||
supplier: row.supplier || null,
|
||||
warehouse: row.warehouse || null,
|
||||
purchaser: row.purchaser || null,
|
||||
area: row.area || null,
|
||||
cooperation: row.cooperation || null,
|
||||
brand: row.brand || null,
|
||||
companySales7d: toNumberOrNull(row.companySales7d),
|
||||
availableStock: toNumberOrNull(row.availableStock),
|
||||
inTransitStock: null, // 待业务提供源表
|
||||
stockDays: toNumberOrNull(row.stockDays),
|
||||
suggestedReplenish: toNumberOrNull(row.suggestedReplenish),
|
||||
produceCycle: toNumberOrNull(row.produceCycle),
|
||||
safetyDays: toNumberOrNull(row.safetyDays),
|
||||
departmentUnshipped: null, // 待业务确认 outOfStock 语义
|
||||
})),
|
||||
latestDate,
|
||||
};
|
||||
}
|
||||
|
||||
function toNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
// ==================== 汇总统计 ====================
|
||||
|
||||
// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致
|
||||
function buildSummary(rows = []) {
|
||||
let outOfStockCount = 0;
|
||||
let lowStockDaysCount = 0;
|
||||
let totalSuggestedReplenish = 0;
|
||||
let needReplenishCount = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const stock = row.availableStock === null ? 0 : Number(row.availableStock);
|
||||
const days = row.stockDays === null ? 0 : Number(row.stockDays);
|
||||
if (stock <= 0) outOfStockCount += 1;
|
||||
else if (days < 7) lowStockDaysCount += 1;
|
||||
|
||||
// ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真
|
||||
const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish);
|
||||
if (replenish > 0) {
|
||||
totalSuggestedReplenish += replenish;
|
||||
needReplenishCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
skuCount: rows.length,
|
||||
outOfStockCount,
|
||||
lowStockDaysCount,
|
||||
totalSuggestedReplenish: Math.round(totalSuggestedReplenish),
|
||||
needReplenishCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ====================
|
||||
|
||||
// 取销售明细表的最新统计日
|
||||
async function getLatestSalesDate() {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}`
|
||||
);
|
||||
return rows[0]?.latestDate || null;
|
||||
}
|
||||
|
||||
// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营
|
||||
async function loadSalesAggregatesBySku() {
|
||||
try {
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return new Map();
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT
|
||||
d.skuoid AS skuoid,
|
||||
SUM(d.sales_amount) AS salesAmount30d,
|
||||
SUM(d.goods_count) AS salesQty30d,
|
||||
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d,
|
||||
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d,
|
||||
MAX(d.profit_center) AS department,
|
||||
MAX(d.shop_dept) AS shopDept
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate)
|
||||
AND d.skuoid IS NOT NULL AND d.skuoid <> N''
|
||||
GROUP BY d.skuoid`,
|
||||
{ days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate },
|
||||
{ requestTimeout: 180000 }
|
||||
);
|
||||
|
||||
const index = new Map();
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.skuoid || '').trim();
|
||||
if (!key) return;
|
||||
index.set(key, {
|
||||
salesAmount7d: toNumberOrNull(row.salesAmount7d),
|
||||
salesQty7d: toNumberOrNull(row.salesQty7d),
|
||||
salesAmount30d: toNumberOrNull(row.salesAmount30d),
|
||||
salesQty30d: toNumberOrNull(row.salesQty30d),
|
||||
department: row.department || row.shopDept || null,
|
||||
});
|
||||
});
|
||||
return index;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径)
|
||||
async function loadMainChannelAndOperator(skuoids) {
|
||||
const result = new Map();
|
||||
if (!Array.isArray(skuoids) || !skuoids.length) return result;
|
||||
|
||||
try {
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return result;
|
||||
|
||||
// 仅对本页 SKU 查询,避免全表聚合
|
||||
const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE);
|
||||
const params = { days: SALES_WINDOW_DAYS, latestDate };
|
||||
const placeholders = list.map((sku, i) => {
|
||||
params[`sku${i}`] = sku;
|
||||
return `@sku${i}`;
|
||||
});
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator,
|
||||
SUM(d.sales_amount) AS amt
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid IN (${placeholders.join(',')})
|
||||
GROUP BY d.skuoid, d.shop_name, d.ider`,
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const byChannel = new Map();
|
||||
const byOperator = new Map();
|
||||
rows.forEach((row) => {
|
||||
const sku = String(row.skuoid || '').trim();
|
||||
const amt = toNumberOrNull(row.amt) || 0;
|
||||
if (!sku) return;
|
||||
if (row.shopName) {
|
||||
const key = `${sku}||${row.shopName}`;
|
||||
const prev = byChannel.get(key) || 0;
|
||||
byChannel.set(key, prev + amt);
|
||||
}
|
||||
if (row.operator) {
|
||||
const key = `${sku}||${row.operator}`;
|
||||
const prev = byOperator.get(key) || 0;
|
||||
byOperator.set(key, prev + amt);
|
||||
}
|
||||
});
|
||||
|
||||
const pickTop = (map, targetMap) => {
|
||||
map.forEach((amt, key) => {
|
||||
const [sku, name] = key.split('||');
|
||||
const prev = targetMap.get(sku);
|
||||
if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt });
|
||||
});
|
||||
};
|
||||
const topChannel = new Map();
|
||||
const topOperator = new Map();
|
||||
pickTop(byChannel, topChannel);
|
||||
pickTop(byOperator, topOperator);
|
||||
|
||||
list.forEach((sku) => {
|
||||
result.set(sku, {
|
||||
mainSalesChannel: topChannel.get(sku)?.name || null,
|
||||
mainSalesOperator: topOperator.get(sku)?.name || null,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数)
|
||||
async function getProductOperators(skuoid) {
|
||||
const sku = String(skuoid || '').trim();
|
||||
if (!sku) return [];
|
||||
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return [];
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT d.ider AS operator,
|
||||
SUM(d.goods_count) AS qty7d,
|
||||
SUM(d.sales_amount) AS amt7d,
|
||||
MAX(d.shop_name) AS sampleShop
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid = @sku
|
||||
AND d.ider IS NOT NULL AND d.ider <> N''
|
||||
GROUP BY d.ider
|
||||
ORDER BY SUM(d.goods_count) DESC`,
|
||||
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
// 每位运营的主渠道
|
||||
const channelRows = await cloudDatabase.query(
|
||||
`SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N''
|
||||
GROUP BY d.ider, d.shop_name`,
|
||||
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const topChannel = new Map();
|
||||
channelRows.forEach((row) => {
|
||||
const amt = toNumberOrNull(row.amt) || 0;
|
||||
const prev = topChannel.get(row.operator);
|
||||
if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt });
|
||||
});
|
||||
|
||||
return rows.map((row) => {
|
||||
const qty7d = toNumberOrNull(row.qty7d) || 0;
|
||||
return {
|
||||
operator: row.operator,
|
||||
dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||
salesQty7d: qty7d,
|
||||
salesAmount7d: toNumberOrNull(row.amt7d),
|
||||
mainChannel: topChannel.get(row.operator)?.name || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 筛选选项 ====================
|
||||
|
||||
async function queryFilterOptions(latestDate) {
|
||||
const [dimensionRows, statusRows] = await Promise.all([
|
||||
cloudDatabase.query(
|
||||
`SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse,
|
||||
m.purchase AS purchaser
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
WHERE m.date = @latestDate`,
|
||||
{ latestDate }
|
||||
),
|
||||
cloudDatabase.query(
|
||||
`SELECT m.sku_idStatus AS status, COUNT(*) AS c
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
WHERE m.date = @latestDate
|
||||
GROUP BY m.sku_idStatus`,
|
||||
{ latestDate }
|
||||
),
|
||||
]);
|
||||
|
||||
const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN'));
|
||||
|
||||
return {
|
||||
categories: uniq('category'),
|
||||
suppliers: uniq('supplier'),
|
||||
warehouses: uniq('warehouse'),
|
||||
purchasers: uniq('purchaser'),
|
||||
saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({
|
||||
status,
|
||||
count: Number(statusRows.find((r) => r.status === status)?.c || 0),
|
||||
})),
|
||||
latestDate,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 数据集缓存 ====================
|
||||
//
|
||||
// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。
|
||||
// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。
|
||||
// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。
|
||||
// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。
|
||||
|
||||
const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
let datasetCache = null;
|
||||
let datasetBuilding = null;
|
||||
|
||||
const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : '');
|
||||
|
||||
async function buildDataset(latestDate) {
|
||||
const key = dateKeyOf(latestDate);
|
||||
const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([
|
||||
queryAllReportRows(latestDate),
|
||||
loadDepartmentTagRules(),
|
||||
loadDepartmentSkuOwners(),
|
||||
loadCompanyTagResolver(),
|
||||
loadCompanyOwnerIndex(),
|
||||
loadSalesAggregatesBySku(),
|
||||
]);
|
||||
|
||||
const rows = baseResult.rows.map((row) => {
|
||||
const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null;
|
||||
|
||||
// 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示
|
||||
const performance30d = salesAgg?.salesAmount30d ?? null;
|
||||
const performance7d = salesAgg?.salesAmount7d ?? null;
|
||||
const departmentSalesQty7d = salesAgg?.salesQty7d ?? null;
|
||||
|
||||
const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d);
|
||||
const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d);
|
||||
const department = salesAgg?.department || null;
|
||||
const owner = resolveDepartmentOwner(departmentOwners, { ...row, department });
|
||||
|
||||
return {
|
||||
...row,
|
||||
companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null,
|
||||
department,
|
||||
// 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示
|
||||
performance7d,
|
||||
performance30d,
|
||||
// 部门近7日日销(件数 / 7)
|
||||
departmentSales7d: departmentSalesQty7d === null
|
||||
? null
|
||||
: Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||
departmentSalesQty7d,
|
||||
// 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0
|
||||
suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish),
|
||||
companyTag,
|
||||
departmentTag,
|
||||
departmentOwner: owner?.ownerName || null,
|
||||
mainSalesChannel: null,
|
||||
mainSalesOperator: null,
|
||||
};
|
||||
});
|
||||
|
||||
return { key, builtAt: Date.now(), rows };
|
||||
}
|
||||
|
||||
async function getDataset(force = false) {
|
||||
const latestDate = await getLatestReportDate();
|
||||
const key = dateKeyOf(latestDate);
|
||||
|
||||
const fresh = datasetCache && datasetCache.key === key
|
||||
&& (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS;
|
||||
if (!force && fresh) return { dataset: datasetCache, latestDate };
|
||||
|
||||
// 并发去重:同一统计日的构建只跑一次
|
||||
if (datasetBuilding && datasetBuilding.key === key) {
|
||||
return { dataset: await datasetBuilding.promise, latestDate };
|
||||
}
|
||||
|
||||
const promise = buildDataset(latestDate)
|
||||
.then((dataset) => {
|
||||
datasetCache = dataset;
|
||||
console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`);
|
||||
return dataset;
|
||||
})
|
||||
.finally(() => { datasetBuilding = null; });
|
||||
|
||||
datasetBuilding = { key, promise };
|
||||
return { dataset: await promise, latestDate };
|
||||
}
|
||||
|
||||
function invalidateDataset() {
|
||||
datasetCache = null;
|
||||
}
|
||||
|
||||
// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试)
|
||||
async function warmProductMonitoringDataset() {
|
||||
try {
|
||||
await getDataset();
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配)
|
||||
function applyBaseFilters(rows = [], query = {}) {
|
||||
const like = (value, keyword) => String(value ?? '').toLowerCase()
|
||||
.includes(String(keyword ?? '').trim().toLowerCase());
|
||||
|
||||
const keywordFields = [
|
||||
['category', 'category'],
|
||||
['spu', 'spu'],
|
||||
['sku', 'skuId'],
|
||||
['productName', 'productName'],
|
||||
['supplier', 'supplier'],
|
||||
['warehouse', 'warehouse'],
|
||||
['purchaser', 'purchaser'],
|
||||
].map(([param, field]) => [field, String(query[param] ?? '').trim()])
|
||||
.filter(([, keyword]) => keyword);
|
||||
|
||||
const saleStatus = String(query.saleStatus ?? '').trim();
|
||||
const includeAll = String(query.includeAll ?? '') === '1';
|
||||
const status = saleStatus || (includeAll ? '' : '销售');
|
||||
|
||||
if (!keywordFields.length && !status) return rows;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (status && String(row.saleStatus || '') !== status) return false;
|
||||
return keywordFields.every(([field, keyword]) => like(row[field], keyword));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 主入口 ====================
|
||||
|
||||
async function getMonitoringList(query = {}) {
|
||||
const forceRefresh = String(query.refresh ?? '') === '1';
|
||||
const { dataset, latestDate } = await getDataset(forceRefresh);
|
||||
|
||||
// 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页
|
||||
const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query);
|
||||
const sorted = sortRows(filtered, query);
|
||||
|
||||
const total = sorted.length;
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE));
|
||||
const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
// 主渠道 / 主运营只对本页 SKU 查询,控制代价
|
||||
const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId));
|
||||
const rows = pageRows.map((row) => {
|
||||
const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {};
|
||||
return {
|
||||
...row,
|
||||
mainSalesChannel: main.mainSalesChannel || null,
|
||||
mainSalesOperator: main.mainSalesOperator || null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
latestDate,
|
||||
cachedAt: dataset.builtAt,
|
||||
summary: buildSummary(sorted),
|
||||
};
|
||||
}
|
||||
|
||||
// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配
|
||||
function applyDerivedFilters(rows = [], query = {}) {
|
||||
const parseList = (value) => String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const companyTags = parseList(query.companyTag);
|
||||
const departmentTags = parseList(query.departmentTag);
|
||||
const departmentKeyword = String(query.department ?? '').trim();
|
||||
const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase();
|
||||
const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase();
|
||||
|
||||
if (!companyTags.length && !departmentTags.length && !departmentKeyword
|
||||
&& !departmentOwnerKeyword && !companyOwnerKeyword) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false;
|
||||
if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false;
|
||||
if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false;
|
||||
if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false;
|
||||
if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(rows = [], query = {}) {
|
||||
const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d';
|
||||
const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC';
|
||||
const direction = desc ? -1 : 1;
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = a[field];
|
||||
const bv = b[field];
|
||||
// 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动
|
||||
if (av === null || av === undefined || av === '') {
|
||||
return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1;
|
||||
}
|
||||
if (bv === null || bv === undefined || bv === '') return -1;
|
||||
|
||||
const an = Number(av);
|
||||
const bn = Number(bv);
|
||||
const bothNumeric = Number.isFinite(an) && Number.isFinite(bn);
|
||||
const delta = bothNumeric
|
||||
? an - bn
|
||||
: String(av).localeCompare(String(bv), 'zh-CN');
|
||||
|
||||
return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签
|
||||
async function loadCompanyTagResolver() {
|
||||
try {
|
||||
const config = await productTagConfigService.getProductTagConfig();
|
||||
const thresholds = config?.scaleThresholds || [];
|
||||
const byCategory = new Map();
|
||||
thresholds.forEach((t) => {
|
||||
if (t.enabled === false) return;
|
||||
const key = String(t.category || '');
|
||||
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||
byCategory.get(key).push(t);
|
||||
});
|
||||
return { byCategory, mappings: config?.sabcMappings || [] };
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停
|
||||
function resolveCompanyTag(config, category, performance, sales) {
|
||||
if (!config) return null;
|
||||
const ofCategory = config.byCategory.get(String(category || ''));
|
||||
if (!ofCategory || !ofCategory.length) return null;
|
||||
// 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空
|
||||
const perf = performance === null || performance === undefined ? 0 : Number(performance);
|
||||
const sale = sales === null || sales === undefined ? 0 : Number(sales);
|
||||
const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale);
|
||||
if (!scaleTag || scaleTag === '滞销款') return 'D';
|
||||
// 利润等级当前无上游数据源,同规模标签下取最优档作为展示值
|
||||
const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false);
|
||||
return hit?.sabcTag || null;
|
||||
}
|
||||
|
||||
// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置
|
||||
function resolveDepartmentOwner(owners, row) {
|
||||
if (!owners.length) return null;
|
||||
const deptName = String(row.department || '').trim();
|
||||
const spu = String(row.spu || '').trim();
|
||||
if (!deptName) return null;
|
||||
|
||||
const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName);
|
||||
if (!ofDept.length) return null;
|
||||
|
||||
const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null;
|
||||
if (bySpu) return bySpu;
|
||||
return ofDept.find((o) => !o.spu) || null;
|
||||
}
|
||||
|
||||
async function getMonitoringFilters() {
|
||||
const latestDate = await getLatestReportDate();
|
||||
const [options, departmentRules] = await Promise.all([
|
||||
queryFilterOptions(latestDate),
|
||||
loadDepartmentTagRules(),
|
||||
]);
|
||||
return {
|
||||
...options,
|
||||
companyTags: ['S', 'A', 'B', 'C', 'D'],
|
||||
departmentTags: DEPARTMENT_TAG_LEVELS,
|
||||
departmentTagRules: departmentRules,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 部门标签配置(系统设置用) ====================
|
||||
|
||||
async function getDepartmentTagConfig() {
|
||||
const [rules, owners, departments, users] = await Promise.all([
|
||||
loadDepartmentTagRules(),
|
||||
loadDepartmentSkuOwners(),
|
||||
database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']),
|
||||
database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'),
|
||||
]);
|
||||
|
||||
return {
|
||||
rules: DEPARTMENT_TAG_LEVELS.map((level) => {
|
||||
const hit = rules.find((r) => r.tagLevel === level);
|
||||
return {
|
||||
tagLevel: level,
|
||||
metricKey: hit?.metricKey || 'sales_qty_7d',
|
||||
thresholdMin: hit?.thresholdMin ?? null,
|
||||
thresholdMax: hit?.thresholdMax ?? null,
|
||||
priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1),
|
||||
enabled: Boolean(hit),
|
||||
};
|
||||
}),
|
||||
metricKeys: [...DEPARTMENT_METRIC_KEYS],
|
||||
owners,
|
||||
departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })),
|
||||
users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveDepartmentTagConfig(payload = {}) {
|
||||
// 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效
|
||||
invalidateDataset();
|
||||
|
||||
const rules = Array.isArray(payload.rules) ? payload.rules : null;
|
||||
const owners = Array.isArray(payload.owners) ? payload.owners : null;
|
||||
|
||||
if (rules) {
|
||||
validateDepartmentRules(rules);
|
||||
await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]);
|
||||
for (const rule of rules) {
|
||||
if (rule.enabled === false) continue;
|
||||
await database.local.query(
|
||||
`INSERT INTO product_tag_rules
|
||||
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
metric_key = VALUES(metric_key),
|
||||
threshold_min = VALUES(threshold_min),
|
||||
threshold_max = VALUES(threshold_max),
|
||||
priority = VALUES(priority),
|
||||
enabled = 1`,
|
||||
[
|
||||
DEPARTMENT_TAG_SCOPE,
|
||||
String(rule.tagLevel),
|
||||
DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d',
|
||||
toNumberOrNull(rule.thresholdMin),
|
||||
toNumberOrNull(rule.thresholdMax),
|
||||
Number(rule.priority) || 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (owners) {
|
||||
validateDepartmentOwners(owners);
|
||||
await database.local.query('DELETE FROM department_sku_owners');
|
||||
for (const owner of owners) {
|
||||
await database.local.query(
|
||||
`INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`,
|
||||
[
|
||||
Number(owner.departmentId),
|
||||
owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner',
|
||||
Number(owner.userId),
|
||||
owner.spu ? String(owner.spu).trim() : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return getDepartmentTagConfig();
|
||||
}
|
||||
|
||||
function validateDepartmentRules(rules) {
|
||||
const seen = new Set();
|
||||
rules.forEach((rule, index) => {
|
||||
const line = index + 1;
|
||||
if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) {
|
||||
throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`);
|
||||
}
|
||||
const min = toNumberOrNull(rule.thresholdMin);
|
||||
const max = toNumberOrNull(rule.thresholdMax);
|
||||
if (min !== null && max !== null && min >= max) {
|
||||
throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`);
|
||||
}
|
||||
if (seen.has(rule.tagLevel)) {
|
||||
throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`);
|
||||
}
|
||||
seen.add(rule.tagLevel);
|
||||
});
|
||||
}
|
||||
|
||||
function validateDepartmentOwners(owners) {
|
||||
const seen = new Set();
|
||||
owners.forEach((owner, index) => {
|
||||
const line = index + 1;
|
||||
if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`);
|
||||
if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`);
|
||||
const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`);
|
||||
seen.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureProductMonitoringSchema,
|
||||
getMonitoringList,
|
||||
getMonitoringFilters,
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
warmProductMonitoringDataset,
|
||||
invalidateDataset,
|
||||
getProductOperators,
|
||||
matchDepartmentTag,
|
||||
DEPARTMENT_TAG_LEVELS,
|
||||
DEPARTMENT_METRIC_KEYS,
|
||||
SALE_STATUS_OPTIONS,
|
||||
};
|
||||
521
backend/src/services/productTagConfigService.js
Normal file
521
backend/src/services/productTagConfigService.js
Normal file
@ -0,0 +1,521 @@
|
||||
'use strict';
|
||||
|
||||
// 商品标签配置:规模标签阈值、利润标签阈值、SABC 映射
|
||||
//
|
||||
// 数据来源:《公司SABC标签配置.xlsx》
|
||||
// - 区块① 分类 × 业绩/日销区间 → 规模标签(148 行)
|
||||
// - 区块② 规模标签 × 利润标签 → SABC(25 行)
|
||||
//
|
||||
// 判定口径(2026-09-11 评审确认):
|
||||
// 1. SABC 保留 5 档,含 D
|
||||
// 2. 利润标签是上游传入的输入值,直接查表;营业利润率仅作展示说明,不参与计算
|
||||
// 3. 区间一律「左闭右开」[起点, 终点);规模标签按 priority 升序命中即停
|
||||
// 4. 业绩与日销必须同时命中
|
||||
// 5. 阈值导入时统一取整
|
||||
// 6. 品类为 '-' 的行跳过
|
||||
// 7. 暂不实现标签自动重算
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const SCALE_TAGS = Object.freeze(['爆款', '热卖款', '动销款', '低动销款']);
|
||||
const FALLBACK_SCALE_TAG = '滞销款';
|
||||
const SCALE_TAG_ORDER = Object.freeze([...SCALE_TAGS, FALLBACK_SCALE_TAG]);
|
||||
const PROFIT_LEVELS = Object.freeze(['一级', '二级', '三级', '四级', '五级']);
|
||||
const SABC_TAGS = Object.freeze(['S', 'A', 'B', 'C', 'D']);
|
||||
|
||||
// 区块① 中作为脏数据占位、导入时跳过的品类值
|
||||
const IGNORED_CATEGORIES = Object.freeze(['-', '']);
|
||||
|
||||
// 有意不参与评级的品类(2026-09-11 决策 B)。
|
||||
// 与「未配置」语义不同:这里是明确决定不评级,不是遗漏,前端要能区分展示。
|
||||
// 存在 system_config 里,无需为它单独建表。
|
||||
const EXCLUDED_CATEGORIES_CONFIG_KEY = 'product_tag_excluded_categories';
|
||||
const DEFAULT_EXCLUDED_CATEGORIES = Object.freeze(['其他 - 配件']);
|
||||
|
||||
// 未评级原因
|
||||
const RATING_SKIP_EXCLUDED = 'excluded';
|
||||
const RATING_SKIP_UNCONFIGURED = 'unconfigured';
|
||||
|
||||
async function ensureTable(tableName, createSql) {
|
||||
await database.local.query(createSql);
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`Table ${tableName} create failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProductTagConfigSchema() {
|
||||
await ensureTable(
|
||||
'product_scale_thresholds',
|
||||
`CREATE TABLE IF NOT EXISTS product_scale_thresholds (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌',
|
||||
display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)',
|
||||
scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款',
|
||||
performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)',
|
||||
performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)',
|
||||
sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)',
|
||||
sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_category_tag (category, scale_tag),
|
||||
KEY idx_category_priority (category, enabled, priority),
|
||||
KEY idx_display_name (display_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'product_profit_levels',
|
||||
`CREATE TABLE IF NOT EXISTS product_profit_levels (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level_code VARCHAR(16) NOT NULL COMMENT '一级~五级',
|
||||
profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)',
|
||||
description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_level_code (level_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'product_sabc_mappings',
|
||||
`CREATE TABLE IF NOT EXISTS product_sabc_mappings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scale_tag VARCHAR(32) NOT NULL,
|
||||
profit_level VARCHAR(16) NOT NULL,
|
||||
sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scale_profit (scale_tag, profit_level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射'`
|
||||
);
|
||||
}
|
||||
|
||||
function toNullableNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
// 决策 5:只对「计算溢出型」脏数据取整,合法的小数(如日销起点 0.5)必须保留。
|
||||
// 判据:小数位数 > 2 位(如 7916.34311148132、2063.13142776357)→ 取整;
|
||||
// 0.5 / 1.25 这类 ≤2 位小数的正常阈值原样保留。
|
||||
function normalizeThreshold(value) {
|
||||
const num = toNullableNumber(value);
|
||||
if (num === null) return null;
|
||||
const decimals = String(num).split('.')[1];
|
||||
if (decimals && decimals.length > 2) return Math.round(num);
|
||||
return num;
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value === null || value === undefined ? '' : value).trim();
|
||||
}
|
||||
|
||||
function assertOneOf(value, allowed, label) {
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`${label} 取值非法:${value},允许值 ${allowed.join(' / ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 读取 ====================
|
||||
|
||||
async function loadScaleThresholds() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled
|
||||
FROM product_scale_thresholds
|
||||
ORDER BY category ASC, priority ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
displayName: row.display_name || row.category,
|
||||
scaleTag: row.scale_tag,
|
||||
performanceMin: toNullableNumber(row.performance_min),
|
||||
performanceMax: toNullableNumber(row.performance_max),
|
||||
salesMin: toNullableNumber(row.sales_min),
|
||||
salesMax: toNullableNumber(row.sales_max),
|
||||
priority: toNumber(row.priority),
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadProfitLevels() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, level_code, profit_rate_max, description, sort, enabled
|
||||
FROM product_profit_levels
|
||||
ORDER BY sort ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
levelCode: row.level_code,
|
||||
profitRateMax: toNullableNumber(row.profit_rate_max),
|
||||
description: row.description || '',
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadSabcMappings() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, scale_tag, profit_level, sabc_tag, sort, enabled
|
||||
FROM product_sabc_mappings
|
||||
ORDER BY sort ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
scaleTag: row.scale_tag,
|
||||
profitLevel: row.profit_level,
|
||||
sabcTag: row.sabc_tag,
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function getProductTagConfig() {
|
||||
const [scaleThresholds, profitLevels, sabcMappings, excludedCategories] = await Promise.all([
|
||||
loadScaleThresholds(),
|
||||
loadProfitLevels(),
|
||||
loadSabcMappings(),
|
||||
loadExcludedCategories(),
|
||||
]);
|
||||
return {
|
||||
scaleThresholds,
|
||||
profitLevels,
|
||||
sabcMappings,
|
||||
excludedCategories,
|
||||
meta: {
|
||||
scaleTags: SCALE_TAG_ORDER,
|
||||
profitLevels: PROFIT_LEVELS,
|
||||
sabcTags: SABC_TAGS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 排除品类(不参与评级) ====================
|
||||
|
||||
async function loadExcludedCategories() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
// 没有配置记录时用默认值(配件不参与评级,决策 B)
|
||||
if (!rows.length || !rows[0].config_value) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
const parsed = JSON.parse(rows[0].config_value);
|
||||
if (!Array.isArray(parsed)) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
return parsed.map((item) => normalizeText(item)).filter(Boolean);
|
||||
} catch (_error) {
|
||||
return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExcludedCategories(list) {
|
||||
const normalized = [...new Set((list || []).map((item) => normalizeText(item)).filter(Boolean))];
|
||||
const payload = JSON.stringify(normalized);
|
||||
const existing = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
if (existing.length) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[payload, 'json', EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.query(
|
||||
'INSERT INTO system_config (config_key, config_value, config_type) VALUES (?, ?, ?)',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY, payload, 'json']
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ==================== 校验 ====================
|
||||
|
||||
function validateScaleThresholds(rows) {
|
||||
const seen = new Set();
|
||||
rows.forEach((row, index) => {
|
||||
const line = index + 1;
|
||||
const category = normalizeText(row.category);
|
||||
if (!category) throw new Error(`第 ${line} 行:品类(jst_sku.category 完整值)不能为空`);
|
||||
if (IGNORED_CATEGORIES.includes(category)) throw new Error(`第 ${line} 行:品类 "${category}" 已标记为忽略,不应提交`);
|
||||
assertOneOf(normalizeText(row.scaleTag), SCALE_TAGS, `第 ${line} 行 规模标签`);
|
||||
|
||||
const key = `${category}||${row.scaleTag}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:品类「${category}」的「${row.scaleTag}」重复`);
|
||||
seen.add(key);
|
||||
|
||||
const pMin = toNullableNumber(row.performanceMin);
|
||||
const pMax = toNullableNumber(row.performanceMax);
|
||||
const sMin = toNullableNumber(row.salesMin);
|
||||
const sMax = toNullableNumber(row.salesMax);
|
||||
|
||||
if (pMin !== null && pMax !== null && pMin >= pMax) {
|
||||
throw new Error(`第 ${line} 行:业绩起点(${pMin}) 必须小于 业绩终点(${pMax})`);
|
||||
}
|
||||
if (sMin !== null && sMax !== null && sMin >= sMax) {
|
||||
throw new Error(`第 ${line} 行:日销起点(${sMin}) 必须小于 日销终点(${sMax})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSabcMappings(rows) {
|
||||
const seen = new Set();
|
||||
rows.forEach((row, index) => {
|
||||
const line = index + 1;
|
||||
assertOneOf(normalizeText(row.scaleTag), SCALE_TAG_ORDER, `第 ${line} 行 规模标签`);
|
||||
assertOneOf(normalizeText(row.profitLevel), PROFIT_LEVELS, `第 ${line} 行 利润标签`);
|
||||
assertOneOf(normalizeText(row.sabcTag), SABC_TAGS, `第 ${line} 行 SABC`);
|
||||
|
||||
const key = `${row.scaleTag}||${row.profitLevel}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:规模标签「${row.scaleTag}」× 利润标签「${row.profitLevel}」重复`);
|
||||
seen.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 保存(整表替换) ====================
|
||||
|
||||
async function replaceScaleThresholds(rows) {
|
||||
validateScaleThresholds(rows);
|
||||
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||
if (!rows.length) return 0;
|
||||
|
||||
const values = [];
|
||||
const placeholders = rows.map((row) => {
|
||||
// priority 由文档顺序推导:爆款=1 热卖款=2 动销款=3 低动销款=4
|
||||
const priority = SCALE_TAGS.indexOf(normalizeText(row.scaleTag)) + 1;
|
||||
values.push(
|
||||
normalizeText(row.category),
|
||||
normalizeText(row.displayName) || normalizeText(row.category),
|
||||
normalizeText(row.scaleTag),
|
||||
normalizeThreshold(row.performanceMin),
|
||||
normalizeThreshold(row.performanceMax),
|
||||
normalizeThreshold(row.salesMin),
|
||||
normalizeThreshold(row.salesMax),
|
||||
priority,
|
||||
toNumber(row.sort, priority),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_scale_thresholds
|
||||
(category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function replaceProfitLevels(rows) {
|
||||
const list = rows && rows.length ? rows : PROFIT_LEVELS.map((levelCode) => ({ levelCode }));
|
||||
list.forEach((row, index) => {
|
||||
assertOneOf(normalizeText(row.levelCode), PROFIT_LEVELS, `第 ${index + 1} 行 利润标签`);
|
||||
});
|
||||
|
||||
const seen = new Set();
|
||||
list.forEach((row, index) => {
|
||||
const code = normalizeText(row.levelCode);
|
||||
if (seen.has(code)) throw new Error(`第 ${index + 1} 行:利润标签「${code}」重复`);
|
||||
seen.add(code);
|
||||
});
|
||||
|
||||
await database.local.query('DELETE FROM product_profit_levels');
|
||||
const values = [];
|
||||
const placeholders = list.map((row, index) => {
|
||||
values.push(
|
||||
normalizeText(row.levelCode),
|
||||
toNullableNumber(row.profitRateMax),
|
||||
normalizeText(row.description) || null,
|
||||
toNumber(row.sort, index + 1),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function replaceSabcMappings(rows) {
|
||||
validateSabcMappings(rows);
|
||||
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||
if (!rows.length) return 0;
|
||||
|
||||
const values = [];
|
||||
const placeholders = rows.map((row, index) => {
|
||||
values.push(
|
||||
normalizeText(row.scaleTag),
|
||||
normalizeText(row.profitLevel),
|
||||
normalizeText(row.sabcTag),
|
||||
toNumber(row.sort, index + 1),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// 一次性保存三个区块(前端「保存配置」按钮)
|
||||
async function saveProductTagConfig(payload = {}) {
|
||||
const result = {};
|
||||
if (Array.isArray(payload.scaleThresholds)) {
|
||||
result.scaleThresholdCount = await replaceScaleThresholds(payload.scaleThresholds);
|
||||
}
|
||||
if (Array.isArray(payload.profitLevels)) {
|
||||
result.profitLevelCount = await replaceProfitLevels(payload.profitLevels);
|
||||
}
|
||||
if (Array.isArray(payload.sabcMappings)) {
|
||||
result.sabcMappingCount = await replaceSabcMappings(payload.sabcMappings);
|
||||
}
|
||||
if (Array.isArray(payload.excludedCategories)) {
|
||||
result.excludedCategoryCount = (await saveExcludedCategories(payload.excludedCategories)).length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 运行时判定 ====================
|
||||
|
||||
// 决策 3 + 4:左闭右开,业绩与日销同时命中;决策 2:按 priority 升序命中即停
|
||||
//
|
||||
// 重要:必须区分两种「没命中」——
|
||||
// ① 该品类有配置阈值,但数值全部落在区间外 → 真·滞销款(兜底档)
|
||||
// ② 该品类**根本没有配置任何阈值** → 返回 null(未评级),不能算滞销款
|
||||
// 否则未配置的品类(如「其他 - 配件」47 万 SKU)会被整批误判成滞销款。
|
||||
function matchScaleTagOfCategory(thresholdsOfCategory, performance, sales) {
|
||||
const ordered = [...(thresholdsOfCategory || [])]
|
||||
.filter((row) => row.enabled !== false)
|
||||
.sort((a, b) => (a.priority - b.priority) || (a.id - b.id));
|
||||
|
||||
if (!ordered.length) return null;
|
||||
|
||||
for (const row of ordered) {
|
||||
const pIn = (row.performanceMin === null || performance >= row.performanceMin)
|
||||
&& (row.performanceMax === null || performance < row.performanceMax);
|
||||
const sIn = (row.salesMin === null || sales >= row.salesMin)
|
||||
&& (row.salesMax === null || sales < row.salesMax);
|
||||
if (pIn && sIn) return row.scaleTag;
|
||||
}
|
||||
return FALLBACK_SCALE_TAG;
|
||||
}
|
||||
|
||||
function buildScaleThresholdIndex(thresholds) {
|
||||
const index = new Map();
|
||||
(thresholds || []).forEach((row) => {
|
||||
const key = normalizeText(row.category);
|
||||
if (!index.has(key)) index.set(key, []);
|
||||
index.get(key).push(row);
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
function buildSabcIndex(mappings) {
|
||||
const index = new Map();
|
||||
(mappings || []).forEach((row) => {
|
||||
if (row.enabled === false) return;
|
||||
index.set(`${normalizeText(row.scaleTag)}||${normalizeText(row.profitLevel)}`, row.sabcTag);
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
// 批量解析:items = [{ category, performance, sales, profitLevel }]
|
||||
async function resolveProductTags(items) {
|
||||
const [thresholds, mappings, excluded] = await Promise.all([
|
||||
loadScaleThresholds(),
|
||||
loadSabcMappings(),
|
||||
loadExcludedCategories(),
|
||||
]);
|
||||
const thresholdIndex = buildScaleThresholdIndex(thresholds);
|
||||
const sabcIndex = buildSabcIndex(mappings);
|
||||
const excludedSet = new Set(excluded);
|
||||
|
||||
return (items || []).map((item) => {
|
||||
const category = normalizeText(item.category);
|
||||
|
||||
// 有意排除的品类:直接返回未评级,且标明原因,不与「未配置」混淆
|
||||
if (excludedSet.has(category)) {
|
||||
return {
|
||||
...item,
|
||||
scaleTag: null,
|
||||
sabcTag: null,
|
||||
rated: false,
|
||||
skippedReason: RATING_SKIP_EXCLUDED,
|
||||
};
|
||||
}
|
||||
|
||||
const rows = thresholdIndex.get(category);
|
||||
const scaleTag = matchScaleTagOfCategory(rows, toNullableNumber(item.performance), toNullableNumber(item.sales));
|
||||
const profitLevel = normalizeText(item.profitLevel);
|
||||
const sabcTag = (scaleTag && profitLevel)
|
||||
? (sabcIndex.get(`${scaleTag}||${profitLevel}`) || null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
scaleTag,
|
||||
sabcTag,
|
||||
rated: scaleTag !== null,
|
||||
// 该品类没有任何阈值配置 → 未评级(不能算滞销款)
|
||||
skippedReason: scaleTag === null ? RATING_SKIP_UNCONFIGURED : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCALE_TAGS,
|
||||
SCALE_TAG_ORDER,
|
||||
FALLBACK_SCALE_TAG,
|
||||
PROFIT_LEVELS,
|
||||
SABC_TAGS,
|
||||
IGNORED_CATEGORIES,
|
||||
DEFAULT_EXCLUDED_CATEGORIES,
|
||||
EXCLUDED_CATEGORIES_CONFIG_KEY,
|
||||
RATING_SKIP_EXCLUDED,
|
||||
RATING_SKIP_UNCONFIGURED,
|
||||
ensureProductTagConfigSchema,
|
||||
getProductTagConfig,
|
||||
saveProductTagConfig,
|
||||
loadExcludedCategories,
|
||||
saveExcludedCategories,
|
||||
resolveProductTags,
|
||||
matchScaleTagOfCategory,
|
||||
// 供单元测试或导入脚本复用
|
||||
normalizeThreshold,
|
||||
validateScaleThresholds,
|
||||
validateSabcMappings,
|
||||
};
|
||||
@ -54,11 +54,8 @@ const STORED_PROFILE_LABELS = {
|
||||
const ENV_MAPPING = {
|
||||
host: 'REDIS_HOST',
|
||||
port: 'REDIS_PORT',
|
||||
username: 'REDIS_USERNAME',
|
||||
password: 'REDIS_PASSWORD',
|
||||
db: 'REDIS_DB',
|
||||
tls: 'REDIS_TLS',
|
||||
tlsServerName: 'REDIS_TLS_SERVER_NAME',
|
||||
};
|
||||
|
||||
const DEFAULT_CHANGZHOU_SERVER_IP = '192.168.150.234';
|
||||
@ -89,12 +86,6 @@ function normalizeDb(value) {
|
||||
return Number.isFinite(db) && db >= 0 ? db : DEFAULT_REDIS_CONFIG.db;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value) {
|
||||
return [true, 1, '1', 'true', 'yes', 'on'].includes(
|
||||
typeof value === 'string' ? value.toLowerCase() : value
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeProfile(value) {
|
||||
const requestedProfile = String(value || '').trim();
|
||||
if (!requestedProfile) {
|
||||
@ -198,10 +189,7 @@ function mapConfig(config = {}, { includePassword = false, source = 'database' }
|
||||
profileName: config.profileName || config.profile_name || STORED_PROFILE_LABELS[profileKey] || profileKey,
|
||||
host,
|
||||
port: normalizePort(config.port),
|
||||
username: String(config.username || '').trim(),
|
||||
db: normalizeDb(config.db),
|
||||
tls: normalizeBoolean(config.tls),
|
||||
tlsServerName: String(config.tlsServerName || config.tls_server_name || '').trim(),
|
||||
hasPassword: Boolean(password),
|
||||
enabled,
|
||||
source,
|
||||
@ -215,11 +203,8 @@ function getEnvRedisConfig(options = {}) {
|
||||
profileName: '.env Redis',
|
||||
host: process.env[ENV_MAPPING.host] || '',
|
||||
port: process.env[ENV_MAPPING.port],
|
||||
username: process.env[ENV_MAPPING.username] || '',
|
||||
db: process.env[ENV_MAPPING.db],
|
||||
password: process.env[ENV_MAPPING.password] || '',
|
||||
tls: process.env[ENV_MAPPING.tls],
|
||||
tlsServerName: process.env[ENV_MAPPING.tlsServerName] || '',
|
||||
enabled: Boolean(String(process.env[ENV_MAPPING.host] || '').trim()),
|
||||
}, {
|
||||
includePassword: Boolean(options.includePassword),
|
||||
@ -369,15 +354,12 @@ async function probeRedisConnection(config) {
|
||||
const client = new Redis({
|
||||
host: config.host,
|
||||
port: normalizePort(config.port),
|
||||
username: config.username || undefined,
|
||||
password: config.password || undefined,
|
||||
db: normalizeDb(config.db),
|
||||
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
|
||||
lazyConnect: true,
|
||||
connectTimeout: REDIS_PROFILE_CONNECT_TIMEOUT_MS,
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
enableReadyCheck: false,
|
||||
retryStrategy: () => null,
|
||||
});
|
||||
|
||||
@ -408,11 +390,8 @@ async function getRuntimeRedisConfig() {
|
||||
return {
|
||||
host: candidate.host,
|
||||
port: candidate.port,
|
||||
username: candidate.username || undefined,
|
||||
password: candidate.password || undefined,
|
||||
db: candidate.db,
|
||||
tls: candidate.tls,
|
||||
tlsServerName: candidate.tlsServerName,
|
||||
source: candidate.source,
|
||||
};
|
||||
}
|
||||
@ -421,11 +400,8 @@ async function getRuntimeRedisConfig() {
|
||||
return {
|
||||
host: candidate.host,
|
||||
port: candidate.port,
|
||||
username: candidate.username || undefined,
|
||||
password: candidate.password || undefined,
|
||||
db: candidate.db,
|
||||
tls: candidate.tls,
|
||||
tlsServerName: candidate.tlsServerName,
|
||||
source: candidate.profileKey || candidate.source,
|
||||
};
|
||||
}
|
||||
@ -435,11 +411,8 @@ async function getRuntimeRedisConfig() {
|
||||
return {
|
||||
host: envCandidate.host,
|
||||
port: envCandidate.port,
|
||||
username: envCandidate.username || undefined,
|
||||
password: envCandidate.password || undefined,
|
||||
db: envCandidate.db,
|
||||
tls: envCandidate.tls,
|
||||
tlsServerName: envCandidate.tlsServerName,
|
||||
source: envCandidate.source,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const REPOSITORY_ROOT = path.resolve(__dirname, '../../..');
|
||||
const SETUP_MODULE_PATH = path.join(REPOSITORY_ROOT, 'scripts', 'setup-remote.mjs');
|
||||
const RESTART_MARKER_PATH = path.join(REPOSITORY_ROOT, '.remote-access', 'restart-requested.json');
|
||||
const DEFAULT_BOOTSTRAP_ENDPOINT = 'https://ck.yydd.ai:8443/v1/enroll';
|
||||
|
||||
function normalizeEndpoint(value) {
|
||||
const endpoint = new URL(String(value || DEFAULT_BOOTSTRAP_ENDPOINT).trim());
|
||||
if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) {
|
||||
throw new Error('配置请求地址必须是没有内嵌账号密码的 HTTPS 地址');
|
||||
}
|
||||
return endpoint.toString();
|
||||
}
|
||||
|
||||
async function enrollRemoteInstallation(input = {}) {
|
||||
const endpoint = normalizeEndpoint(input.endpoint);
|
||||
const apiKey = String(input.apiKey || '').trim();
|
||||
const label = String(input.label || os.hostname()).trim();
|
||||
if (!apiKey) throw new Error('请输入部署 Key');
|
||||
if (!label || label.length > 80) throw new Error('设备名称必须为 1 到 80 个字符');
|
||||
|
||||
const setupModule = await import(pathToFileURL(SETUP_MODULE_PATH).href);
|
||||
const { enrollment, connection } = await setupModule.configureRemoteAccess({
|
||||
endpoint,
|
||||
key: apiKey,
|
||||
label,
|
||||
replace: false,
|
||||
});
|
||||
|
||||
return {
|
||||
installationId: enrollment.installation_id,
|
||||
certificateValidUntil: enrollment.ssh.valid_until,
|
||||
systemDatabase: {
|
||||
host: '127.0.0.1',
|
||||
port: connection.localDatabasePort,
|
||||
database: enrollment.database.name,
|
||||
},
|
||||
redis: {
|
||||
host: enrollment.redis.host,
|
||||
port: Number(enrollment.redis.port),
|
||||
tls: true,
|
||||
},
|
||||
adminUsername: enrollment.application_admin.username,
|
||||
adminPassword: enrollment.application_admin.password,
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRemoteRestart() {
|
||||
fs.mkdirSync(path.dirname(RESTART_MARKER_PATH), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
RESTART_MARKER_PATH,
|
||||
`${JSON.stringify({ requestedAt: new Date().toISOString() })}\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
fs.chmodSync(RESTART_MARKER_PATH, 0o600);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_BOOTSTRAP_ENDPOINT,
|
||||
enrollRemoteInstallation,
|
||||
normalizeEndpoint,
|
||||
scheduleRemoteRestart,
|
||||
};
|
||||
@ -1,36 +1,14 @@
|
||||
const RETIRED_PAGE_KEYS = [
|
||||
'planning_upload', 'market_insight', 'agent', 'ai_skill_tags',
|
||||
'store_checkup', 'review_analysis', 'customer_service_checkup',
|
||||
'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video',
|
||||
'product_monitoring', 'ops_b_incentive', 'marketing_events', 'promotion_module', 'product_analysis',
|
||||
];
|
||||
const RETIRED_ENTRY_KEYS = new Set([
|
||||
'market-insight', 'agent', 'ai-skill-tags', 'platform-switch',
|
||||
'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup',
|
||||
'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video',
|
||||
'desktop-client-releases',
|
||||
'product-monitoring', 'ops-b-incentive',
|
||||
'marketing-management', 'marketing-events', 'promotion-module',
|
||||
'product-management', 'product-analysis',
|
||||
]);
|
||||
const RETIRED_PAGE_KEYS = ['planning_upload', 'market_insight', 'agent', 'ai_skill_tags'];
|
||||
const RETIRED_ENTRY_KEYS = new Set(['market-insight', 'agent', 'ai-skill-tags', 'platform-switch']);
|
||||
const RETIRED_CONFIG_KEYS = [
|
||||
'openclaw_chat_config', 'openclaw_chat_skill_tags',
|
||||
'selection_similar_products_config', 'alibaba_1688_image_search_config',
|
||||
'cs_quality_ai_config', 'desktop_client_releases',
|
||||
'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url',
|
||||
'ai_buyer_show_vision_model', 'ai_buyer_show_review_model',
|
||||
'product_tag_excluded_categories',
|
||||
];
|
||||
const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database'];
|
||||
|
||||
function isRetiredSystemConfigKey(key) {
|
||||
return RETIRED_CONFIG_KEYS.includes(String(key || '').trim());
|
||||
}
|
||||
|
||||
function isRetiredDatabaseConfigName(name) {
|
||||
return RETIRED_DATABASE_CONFIG_NAMES.includes(String(name || '').trim());
|
||||
}
|
||||
|
||||
function pruneRetiredSidebarConfig(value) {
|
||||
try {
|
||||
const config = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
@ -50,18 +28,6 @@ async function cleanupRetiredFeatureSettings(db) {
|
||||
await db.query(`DELETE FROM ${table} WHERE page_key IN (${RETIRED_PAGE_KEYS.map(() => '?').join(',')})`, RETIRED_PAGE_KEYS);
|
||||
}
|
||||
await db.query(`DELETE FROM system_config WHERE config_key IN (${RETIRED_CONFIG_KEYS.map(() => '?').join(',')})`, RETIRED_CONFIG_KEYS);
|
||||
await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES);
|
||||
await db.query('DROP TABLE IF EXISTS pricing_calculator_history');
|
||||
await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs');
|
||||
await db.query('DROP TABLE IF EXISTS ops_b_incentive_snapshots');
|
||||
await db.query('DROP TABLE IF EXISTS ops_b_incentive_periods');
|
||||
await db.query('DROP TABLE IF EXISTS product_analysis_user_prefs');
|
||||
await db.query('DROP TABLE IF EXISTS pdd_goods_first_seen');
|
||||
await db.query('DROP TABLE IF EXISTS department_sku_owners');
|
||||
await db.query('DROP TABLE IF EXISTS product_tag_rules');
|
||||
await db.query('DROP TABLE IF EXISTS product_sabc_mappings');
|
||||
await db.query('DROP TABLE IF EXISTS product_profit_levels');
|
||||
await db.query('DROP TABLE IF EXISTS product_scale_thresholds');
|
||||
const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']);
|
||||
if (rows.length) {
|
||||
const current = rows[0].config_value;
|
||||
@ -70,12 +36,4 @@ async function cleanupRetiredFeatureSettings(db) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RETIRED_PAGE_KEYS,
|
||||
RETIRED_CONFIG_KEYS,
|
||||
RETIRED_DATABASE_CONFIG_NAMES,
|
||||
isRetiredSystemConfigKey,
|
||||
isRetiredDatabaseConfigName,
|
||||
pruneRetiredSidebarConfig,
|
||||
cleanupRetiredFeatureSettings,
|
||||
};
|
||||
module.exports = { RETIRED_PAGE_KEYS, RETIRED_CONFIG_KEYS, isRetiredSystemConfigKey, pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings };
|
||||
|
||||
255
backend/test/csQualityService.test.js
Normal file
255
backend/test/csQualityService.test.js
Normal file
@ -0,0 +1,255 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
QWEN_PROVIDER,
|
||||
AGNES_PROVIDER,
|
||||
DEFAULT_QWEN_BASE_URL,
|
||||
DEFAULT_QWEN_MODEL,
|
||||
DEFAULT_AGNES_BASE_URL,
|
||||
DEFAULT_AGNES_MODEL,
|
||||
_test: {
|
||||
resolveAiConfig,
|
||||
buildAiRequestPayload,
|
||||
parseModelJson,
|
||||
mapChatDataMeta,
|
||||
aggregateChatDimensionRows,
|
||||
createChatBatches,
|
||||
normalizeAnalysis,
|
||||
mergeBatchAnalysesLocally,
|
||||
reconcileMergedAnalysis,
|
||||
sumUsages,
|
||||
getAiErrorDetail,
|
||||
isRetryableAiError,
|
||||
getAiRetryDelayMs,
|
||||
sanitizePublicError,
|
||||
},
|
||||
} = require('../src/services/csQualityService');
|
||||
|
||||
test('chat data metadata exposes the actual available date range', () => {
|
||||
assert.deepEqual(mapChatDataMeta({
|
||||
minDate: '2026-06-22',
|
||||
latestDate: '2026-07-31 23:59:59',
|
||||
messageCount: '792771',
|
||||
}), {
|
||||
tableName: 'rpt.pdd_GoodsChatMessages',
|
||||
minDate: '2026-06-22',
|
||||
latestDate: '2026-07-31',
|
||||
messageCount: 792771,
|
||||
});
|
||||
});
|
||||
|
||||
function message(uid, content, index = 1) {
|
||||
return {
|
||||
uid,
|
||||
nickname: `买家-${uid}`,
|
||||
msgId: `${uid}-${index}`,
|
||||
msgTime: `2026-07-30 10:00:${String(index).padStart(2, '0')}`,
|
||||
directionLabel: '买家',
|
||||
csname: '客服A',
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function result(overrides = {}) {
|
||||
return {
|
||||
consultation_type: '售前',
|
||||
primary_reason: '产品问题',
|
||||
secondary_reason: '尺寸问题',
|
||||
occurrence_count: 1,
|
||||
affected_customer_count: 1,
|
||||
affected_conversation_ids: ['u1'],
|
||||
buyer_intent: '咨询尺寸',
|
||||
evidence: [{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }],
|
||||
image_urls: [],
|
||||
product_improvement_areas: ['详情页'],
|
||||
improvement_suggestion: '补充尺寸图',
|
||||
confidence: 0.9,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('customer-service quality defaults to the verified Qwen model and endpoint', () => {
|
||||
const config = resolveAiConfig({ apiKey: 'test-key' });
|
||||
assert.equal(QWEN_PROVIDER, 'aliyun-model-studio');
|
||||
assert.equal(config.provider, QWEN_PROVIDER);
|
||||
assert.equal(config.baseUrl, DEFAULT_QWEN_BASE_URL);
|
||||
assert.equal(config.model, DEFAULT_QWEN_MODEL);
|
||||
assert.equal(config.batchConcurrency, 2);
|
||||
assert.equal(config.batchMaxChars, 45000);
|
||||
assert.equal(config.mergeMaxBatches, 8);
|
||||
assert.equal(config.requestRetryCount, 2);
|
||||
assert.equal(config.retryBaseDelayMs, 1500);
|
||||
});
|
||||
|
||||
test('customer-service quality resolves the Agnes China endpoint and request format', () => {
|
||||
const config = resolveAiConfig({
|
||||
provider: AGNES_PROVIDER,
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
assert.equal(config.provider, AGNES_PROVIDER);
|
||||
assert.equal(config.baseUrl, DEFAULT_AGNES_BASE_URL);
|
||||
assert.equal(config.model, DEFAULT_AGNES_MODEL);
|
||||
assert.equal(config.timeoutMs, 600000);
|
||||
|
||||
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
|
||||
assert.equal(payload.model, DEFAULT_AGNES_MODEL);
|
||||
assert.equal(payload.max_tokens, 2048);
|
||||
assert.equal(payload.temperature, 0);
|
||||
assert.equal(payload.stream, false);
|
||||
assert.equal('max_completion_tokens' in payload, false);
|
||||
assert.equal('response_format' in payload, false);
|
||||
});
|
||||
|
||||
test('Qwen requests retain the provider-specific JSON parameters', () => {
|
||||
const config = resolveAiConfig({ apiKey: 'test-key' });
|
||||
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
|
||||
assert.equal(payload.max_completion_tokens, 2048);
|
||||
assert.deepEqual(payload.response_format, { type: 'json_object' });
|
||||
assert.equal(payload.enable_thinking, false);
|
||||
assert.equal('max_tokens' in payload, false);
|
||||
});
|
||||
|
||||
test('chat batching keeps every customer conversation intact', () => {
|
||||
const rows = [
|
||||
message('u1', '甲'.repeat(700), 1),
|
||||
message('u1', '乙'.repeat(700), 2),
|
||||
message('u2', '丙'.repeat(700), 1),
|
||||
message('u3', '丁'.repeat(700), 1),
|
||||
];
|
||||
const batches = createChatBatches(rows, 1300);
|
||||
|
||||
assert.equal(batches.length, 3);
|
||||
assert.deepEqual(batches[0].conversationIds, ['u1']);
|
||||
assert.equal(batches[0].messageCount, 2);
|
||||
assert.ok(batches.every((batch) => !(
|
||||
batch.conversationIds.includes('u1') && batch.conversationIds.length > 1
|
||||
)));
|
||||
assert.deepEqual(batches.flatMap((batch) => batch.conversationIds), ['u1', 'u2', 'u3']);
|
||||
assert.equal(batches[0].startDate, '2026-07-30');
|
||||
assert.equal(batches[0].endDate, '2026-07-30');
|
||||
});
|
||||
|
||||
test('chat dimension snapshot derives goods and customers in one aggregation', () => {
|
||||
const snapshot = aggregateChatDimensionRows([
|
||||
{
|
||||
goodsId: 'g1', uid: 'u1', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
|
||||
nickname: '客户1', messageCount: 2, firstMsgTime: '2026-07-29T08:00:00.000Z', lastMsgTime: '2026-07-29T08:01:00.000Z',
|
||||
},
|
||||
{
|
||||
goodsId: 'g1', uid: 'u1', csname: '客服B', goodsName: '商品1', shopName: '店铺1',
|
||||
nickname: '客户1', messageCount: 3, firstMsgTime: '2026-07-29T08:02:00.000Z', lastMsgTime: '2026-07-29T08:04:00.000Z',
|
||||
},
|
||||
{
|
||||
goodsId: 'g1', uid: 'u2', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
|
||||
nickname: '客户2', messageCount: 1, firstMsgTime: '2026-07-29T09:00:00.000Z', lastMsgTime: '2026-07-29T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
goodsId: 'g2', uid: 'u3', csname: '客服C', goodsName: '商品2', shopName: '店铺2',
|
||||
nickname: '客户3', messageCount: 4, firstMsgTime: '2026-07-29T10:00:00.000Z', lastMsgTime: '2026-07-29T10:03:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(snapshot.goodsRows.length, 2);
|
||||
assert.equal(snapshot.goodsRows[0].goodsId, 'g1');
|
||||
assert.equal(snapshot.goodsRows[0].messageCount, 6);
|
||||
assert.equal(snapshot.goodsRows[0].userCount, 2);
|
||||
assert.equal(snapshot.goodsRows[0].csCount, 2);
|
||||
assert.equal(snapshot.customersByGoods.g1.length, 2);
|
||||
assert.equal(snapshot.customersByGoods.g1[0].uid, 'u1');
|
||||
assert.equal(snapshot.customersByGoods.g1[0].messageCount, 5);
|
||||
assert.equal(snapshot.customersByGoods.g1[0].csname, '客服B');
|
||||
assert.equal(snapshot.customersByGoods.g1[0].firstMsgTime, '2026-07-29T08:00:00.000Z');
|
||||
assert.equal(snapshot.customersByGoods.g1[0].lastMsgTime, '2026-07-29T08:04:00.000Z');
|
||||
});
|
||||
|
||||
test('local merge sums disjoint batch counts and deduplicates evidence', () => {
|
||||
const analyses = [
|
||||
{
|
||||
summary: { pre_sale_conversation_count: 1, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
|
||||
results: [result()],
|
||||
},
|
||||
{
|
||||
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
|
||||
results: [result({
|
||||
occurrence_count: 3,
|
||||
affected_customer_count: 2,
|
||||
affected_conversation_ids: ['u1', 'u2'],
|
||||
evidence: [
|
||||
{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' },
|
||||
{ conversation_id: 'u2', nickname: '买家-u2', message_time: '2026-07-30 10:00:02', text: '多宽' },
|
||||
],
|
||||
})],
|
||||
},
|
||||
];
|
||||
const merged = mergeBatchAnalysesLocally(analyses, { customerCount: 3, messageCount: 12 });
|
||||
|
||||
assert.equal(merged.results.length, 1);
|
||||
assert.equal(merged.results[0].occurrence_count, 4);
|
||||
assert.equal(merged.results[0].affected_customer_count, 3);
|
||||
assert.deepEqual(merged.results[0].affected_conversation_ids, ['u1', 'u2']);
|
||||
assert.equal(merged.results[0].evidence.length, 2);
|
||||
assert.equal(merged.summary.conversation_count, 3);
|
||||
assert.equal(merged.summary.pre_sale_conversation_count, 3);
|
||||
assert.deepEqual(merged.summary.main_findings, ['尺寸咨询较多']);
|
||||
});
|
||||
|
||||
test('model merge cannot change deterministic counts or drop a label group', () => {
|
||||
const local = mergeBatchAnalysesLocally([{
|
||||
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: [] },
|
||||
results: [result({ occurrence_count: 4, affected_customer_count: 2 })],
|
||||
}], { customerCount: 2, messageCount: 8 });
|
||||
const model = {
|
||||
schema_version: '1.0',
|
||||
conversation_type: '售前',
|
||||
summary: { conversation_count: 99, pre_sale_conversation_count: 99, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['优化后的结论'] },
|
||||
results: [result({ occurrence_count: 99, affected_customer_count: 99, buyer_intent: '优化后的诉求' })],
|
||||
};
|
||||
const reconciled = reconcileMergedAnalysis(model, local, { customerCount: 2, messageCount: 8 });
|
||||
|
||||
assert.equal(reconciled.results[0].occurrence_count, 4);
|
||||
assert.equal(reconciled.results[0].affected_customer_count, 2);
|
||||
assert.equal(reconciled.results[0].buyer_intent, '优化后的诉求');
|
||||
assert.deepEqual(reconciled.results[0].affected_conversation_ids, ['u1']);
|
||||
assert.equal(reconciled.summary.conversation_count, 2);
|
||||
assert.equal(reconciled.summary.pre_sale_conversation_count, 2);
|
||||
});
|
||||
|
||||
test('analysis normalization keeps every valid affected conversation id', () => {
|
||||
const normalized = normalizeAnalysis({
|
||||
summary: {},
|
||||
results: [{
|
||||
consultation_type: '售前',
|
||||
primary_reason: '产品问题',
|
||||
secondary_reason: '尺寸问题',
|
||||
occurrence_count: 3,
|
||||
affected_customer_count: 3,
|
||||
affected_conversation_ids: ['u1', 'u2', 'not-in-batch'],
|
||||
evidence: [{ conversation_id: 'u3', text: '尺寸是多少' }],
|
||||
}],
|
||||
}, { customerCount: 3, messageCount: 10 }, {
|
||||
allowedConversationIds: ['u1', 'u2', 'u3'],
|
||||
});
|
||||
|
||||
assert.deepEqual(normalized.results[0].affected_conversation_ids, ['u1', 'u2', 'u3']);
|
||||
assert.equal(normalized.results[0].affected_customer_count, 3);
|
||||
});
|
||||
|
||||
test('Qwen JSON parsing, usage aggregation and error extraction are provider-safe', () => {
|
||||
assert.deepEqual(parseModelJson('```json\n{"ok":true}\n```'), { ok: true });
|
||||
assert.deepEqual(sumUsages([
|
||||
{ prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
|
||||
{ input_tokens: 7, output_tokens: 2 },
|
||||
]), { prompt_tokens: 17, completion_tokens: 5, total_tokens: 22 });
|
||||
assert.equal(getAiErrorDetail({ response: { data: { error: { message: 'rate limited' } } } }), 'rate limited');
|
||||
assert.equal(getAiErrorDetail({ message: 'Bearer sk-secret-example-1234567890 failed' }), 'Bearer [redacted] failed');
|
||||
assert.equal(isRetryableAiError({ code: 'ETIMEDOUT' }), true);
|
||||
assert.equal(isRetryableAiError({ response: { status: 429 } }), true);
|
||||
assert.equal(isRetryableAiError({ response: { status: 401 } }), false);
|
||||
assert.equal(getAiRetryDelayMs({}, 1, 1500), 1500);
|
||||
assert.equal(getAiRetryDelayMs({}, 2, 1500), 3000);
|
||||
assert.equal(getAiRetryDelayMs({ response: { headers: { 'retry-after': '2' } } }, 1, 1500), 2000);
|
||||
assert.equal(sanitizePublicError('千问模型请求失败:qwen-timeout'), 'AI服务请求失败:AI服务');
|
||||
assert.equal(sanitizePublicError('https://apihub.agnes-ai.com/v1 failed'), 'https://AI服务/v1 failed');
|
||||
});
|
||||
@ -1,54 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const Module = require('node:module');
|
||||
const express = require('express');
|
||||
|
||||
function loadDashboardRouter() {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(id, parent, ...rest) {
|
||||
if (parent?.filename.endsWith('/api/dashboard.js') && id.endsWith('/jianSunDatabase')) {
|
||||
return {
|
||||
query: async (sql) => {
|
||||
if (sql.includes('SELECT DISTINCT shopName')) return [{ shopName: '测试店铺' }];
|
||||
if (sql.includes('FROM dbo.pdd_DataGoods g')) {
|
||||
return [{
|
||||
statDate: '2026-09-12', latestDate: '2026-09-12', payAmount: 100,
|
||||
buyerCount: 2, visitorCount: 10, goodsPv: 20, payGoodsQty: 3,
|
||||
payOrderCount: 2, favoriteCount: 1, impressionCount: 30,
|
||||
}];
|
||||
}
|
||||
if (sql.includes('FROM dbo.pdd_MallServiceScore')) return [{ shopCount: 1, cstmrServScore: 4.8 }];
|
||||
if (sql.includes('FROM dbo.pdd_ShopStars')) return [{ shopCount: 1, mallStarTomms: 5 }];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, id, parent, ...rest);
|
||||
};
|
||||
try {
|
||||
return require('../src/api/dashboard');
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
test('dashboard keeps its read-only data endpoints after product analysis is retired', async () => {
|
||||
const app = express();
|
||||
app.use('/api/dashboard', loadDashboardRouter());
|
||||
const server = await new Promise((resolve) => {
|
||||
const listener = app.listen(0, '127.0.0.1', () => resolve(listener));
|
||||
});
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const meta = await (await fetch(`${base}/api/dashboard/meta`)).json();
|
||||
assert.deepEqual(meta.data.shops, ['测试店铺']);
|
||||
|
||||
const summary = await (await fetch(`${base}/api/dashboard/summary?period=day`)).json();
|
||||
assert.equal(summary.success, true);
|
||||
assert.equal(summary.data.latestDate, '2026-09-12');
|
||||
assert.equal(summary.data.metrics.find((item) => item.key === 'payAmount').value, 100);
|
||||
assert.equal((await fetch(`${base}/api/product-analysis/products`)).status, 404);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
60
backend/test/desktopReleaseService.test.js
Normal file
60
backend/test/desktopReleaseService.test.js
Normal file
@ -0,0 +1,60 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
compareVersions,
|
||||
normalizeArchitecture,
|
||||
normalizePlatform,
|
||||
parseReleaseStorage,
|
||||
validateVersion,
|
||||
} = require('../src/services/desktopReleaseService');
|
||||
|
||||
test('desktop release versions are compared numerically', () => {
|
||||
assert.equal(compareVersions('0.0.10', '0.0.9'), 1);
|
||||
assert.equal(compareVersions('v1.2.3', '1.2.3'), 0);
|
||||
assert.equal(compareVersions('2.0.0', '10.0.0'), -1);
|
||||
});
|
||||
|
||||
test('desktop release versions require an x.y.z value', () => {
|
||||
assert.equal(validateVersion('v1.2.3'), '1.2.3');
|
||||
assert.throws(() => validateVersion('1.2'), /版本号格式/);
|
||||
});
|
||||
|
||||
test('desktop release storage drops invalid records without losing the latest version', () => {
|
||||
const storage = parseReleaseStorage(JSON.stringify({
|
||||
latestVersion: '0.0.9',
|
||||
releases: [
|
||||
{ id: 9, version: '0.0.9', objectKey: 'desktop-client/0.0.9/setup.exe' },
|
||||
{ id: 8, version: 'invalid', objectKey: 'desktop-client/invalid/setup.exe' },
|
||||
],
|
||||
}));
|
||||
|
||||
assert.equal(storage.latestVersion, '0.0.9');
|
||||
assert.equal(storage.releases.length, 1);
|
||||
assert.equal(storage.releases[0].version, '0.0.9');
|
||||
assert.equal(storage.releases[0].platform, 'win32');
|
||||
assert.equal(storage.releases[0].architecture, 'x64');
|
||||
assert.equal(storage.latestVersions.win32, '0.0.9');
|
||||
});
|
||||
|
||||
test('desktop release platform and architecture are inferred from installers', () => {
|
||||
assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-arm64.dmg'), 'darwin');
|
||||
assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-setup.exe'), 'win32');
|
||||
assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-arm64.dmg', 'darwin'), 'arm64');
|
||||
assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-setup.exe', 'win32'), 'x64');
|
||||
});
|
||||
|
||||
test('desktop release storage keeps Windows and macOS installers for the same version', () => {
|
||||
const storage = parseReleaseStorage({
|
||||
latestVersions: { win32: '0.0.10', darwin: '0.0.10' },
|
||||
releases: [
|
||||
{ id: 10, version: '0.0.10', objectKey: 'desktop-client/win32/0.0.10/setup.exe', fileName: 'pdd-auto-sale-0.0.10-setup.exe' },
|
||||
{ id: 11, version: '0.0.10', objectKey: 'desktop-client/darwin/0.0.10/app.dmg', fileName: 'pdd-auto-sale-0.0.10-arm64.dmg' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(storage.releases.length, 2);
|
||||
assert.deepEqual(storage.releases.map((release) => release.platform), ['win32', 'darwin']);
|
||||
assert.equal(storage.latestVersions.win32, '0.0.10');
|
||||
assert.equal(storage.latestVersions.darwin, '0.0.10');
|
||||
});
|
||||
@ -92,9 +92,12 @@ test('desktop capabilities follow resolved page permissions', () => {
|
||||
shopManagement: false,
|
||||
execution: false,
|
||||
logs: true,
|
||||
marketingEvents: false,
|
||||
promotion: false,
|
||||
productTest: false,
|
||||
reviewAnalysis: false,
|
||||
});
|
||||
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).productTest, true);
|
||||
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true);
|
||||
});
|
||||
|
||||
test('SSO ticket is consumed exactly once and produces a normal web token', async () => {
|
||||
|
||||
@ -5,6 +5,7 @@ const assert = require('node:assert/strict');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const presaleRouter = require('../src/api/presaleExternal');
|
||||
const autoListingRouter = require('../src/api/planningSelectionFissionExternal');
|
||||
const pricingRouter = require('../src/api/pricingCalculatorExternal');
|
||||
const alibaba1688Router = require('../src/api/alibaba1688ProductExternal');
|
||||
const {
|
||||
requireDesktopPageAccess,
|
||||
@ -37,6 +38,40 @@ test('presale and auto-listing external APIs use the same request key middleware
|
||||
assert.equal(getRouteMiddleware(alibaba1688Router, '/:productId'), requireExternalAutomationApiKey);
|
||||
});
|
||||
|
||||
test('desktop pricing uses the shared request key middleware', () => {
|
||||
const middlewareLayer = pricingRouter.stack.find((item) => item.name === 'requireExternalAutomationApiKey');
|
||||
assert.equal(middlewareLayer?.handle, requireExternalAutomationApiKey);
|
||||
});
|
||||
|
||||
test('desktop pricing identity is stable, isolated and uses negative safe integers', () => {
|
||||
const first = pricingRouter.desktopPricingUserId('香茗');
|
||||
assert.equal(first, pricingRouter.desktopPricingUserId(' 香茗 '));
|
||||
assert.notEqual(first, pricingRouter.desktopPricingUserId('江陵'));
|
||||
assert.equal(Number.isSafeInteger(first), true);
|
||||
assert.equal(first < 0, true);
|
||||
});
|
||||
|
||||
test('desktop pricing accepts a UTF-8 base64url nickname header', () => {
|
||||
const nickname = '胖大海';
|
||||
const req = {
|
||||
get(name) {
|
||||
return name.toLowerCase() === 'x-operator-name-encoded'
|
||||
? Buffer.from(nickname, 'utf8').toString('base64url')
|
||||
: '';
|
||||
},
|
||||
query: {},
|
||||
body: {},
|
||||
};
|
||||
let nextCalled = false;
|
||||
|
||||
pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; });
|
||||
|
||||
assert.equal(nextCalled, true);
|
||||
assert.equal(req.user.name, nickname);
|
||||
assert.equal(req.user.id, req.user.sub);
|
||||
assert.ok(req.user.id < 0);
|
||||
});
|
||||
|
||||
test('shared external auth accepts a valid desktop JWT without an API key', async () => {
|
||||
const previousSecret = process.env.CUBEJS_SECRET;
|
||||
process.env.CUBEJS_SECRET = 'desktop-session-test-secret';
|
||||
@ -143,3 +178,20 @@ test('desktop page access enforces JWT permissions and preserves API key compati
|
||||
assert.equal(nextCalled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('desktop pricing keeps the verified JWT user identity', () => {
|
||||
const req = {
|
||||
externalApiKey: { authType: 'jwt' },
|
||||
user: { sub: '42', id: 42, name: '陆南亭' },
|
||||
get() { return ''; },
|
||||
query: {},
|
||||
body: {},
|
||||
};
|
||||
let nextCalled = false;
|
||||
|
||||
pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; });
|
||||
|
||||
assert.equal(nextCalled, true);
|
||||
assert.equal(req.user.id, 42);
|
||||
assert.equal(req.user.name, '陆南亭');
|
||||
});
|
||||
|
||||
110
backend/test/pricingCalculatorSelection.test.js
Normal file
110
backend/test/pricingCalculatorSelection.test.js
Normal file
@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
_test: {
|
||||
filterSkuRowsForSelectionContext,
|
||||
createPrefsPatch,
|
||||
getSelectionSkuCodeSet,
|
||||
normalizeCalculatorType,
|
||||
readPrefsProfile,
|
||||
sanitizeHistoryProduct,
|
||||
writePrefsProfile,
|
||||
},
|
||||
} = require('../src/api/pricingCalculator');
|
||||
|
||||
const skuRows = [
|
||||
{ skuCode: 'SKU-A' },
|
||||
{ skuCode: 'SKU-B' },
|
||||
{ skuCode: 'SKU-C' },
|
||||
];
|
||||
|
||||
test('selection pricing only returns explicitly selected SKU rows', () => {
|
||||
const selectionContext = {
|
||||
skuSceneImages: { selectedSkuCodes: ['sku-a', 'SKU-C'] },
|
||||
rows: [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-B' }, { skuCode: 'SKU-C' }],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
filterSkuRowsForSelectionContext(skuRows, selectionContext),
|
||||
[{ skuCode: 'SKU-A' }, { skuCode: 'SKU-C' }]
|
||||
);
|
||||
});
|
||||
|
||||
test('selection pricing falls back to stored pricing rows for legacy tasks', () => {
|
||||
const selectionContext = {
|
||||
skuSceneImages: {},
|
||||
rows: [{ skuCode: 'SKU-B' }],
|
||||
};
|
||||
|
||||
assert.deepEqual([...getSelectionSkuCodeSet(selectionContext)], ['SKU-B']);
|
||||
assert.deepEqual(
|
||||
filterSkuRowsForSelectionContext(skuRows, selectionContext),
|
||||
[{ skuCode: 'SKU-B' }]
|
||||
);
|
||||
});
|
||||
|
||||
test('standalone pricing continues to return all SPU SKU rows', () => {
|
||||
assert.deepEqual(filterSkuRowsForSelectionContext(skuRows, null), skuRows);
|
||||
});
|
||||
|
||||
test('pricing preferences keep legacy company values and isolate 1688 defaults', () => {
|
||||
const legacy = JSON.stringify({
|
||||
coupon: 30,
|
||||
activityDiscount: 0.7,
|
||||
installFee: 5,
|
||||
targetMargin: 0.15,
|
||||
});
|
||||
const withDropship = writePrefsProfile(legacy, 'alibaba1688', {
|
||||
coupon: 20,
|
||||
activityDiscount: 0.6,
|
||||
installFee: 0,
|
||||
targetMargin: 0.12,
|
||||
});
|
||||
|
||||
assert.deepEqual(readPrefsProfile(withDropship, 'company'), {
|
||||
coupon: 30,
|
||||
activityDiscount: 0.7,
|
||||
installFee: 5,
|
||||
targetMargin: 0.15,
|
||||
});
|
||||
assert.deepEqual(readPrefsProfile(withDropship, 'alibaba1688'), {
|
||||
coupon: 20,
|
||||
activityDiscount: 0.6,
|
||||
installFee: 0,
|
||||
targetMargin: 0.12,
|
||||
});
|
||||
|
||||
const updatedCompany = writePrefsProfile(withDropship, 'company', { targetMargin: 0.18 });
|
||||
assert.equal(readPrefsProfile(updatedCompany, 'company').targetMargin, 0.18);
|
||||
assert.equal(readPrefsProfile(updatedCompany, 'alibaba1688').targetMargin, 0.12);
|
||||
assert.deepEqual(createPrefsPatch('company', { targetMargin: 0.15 }), { targetMargin: 0.15 });
|
||||
assert.deepEqual(createPrefsPatch('alibaba1688', { targetMargin: 0.12 }), {
|
||||
_profiles: { alibaba1688: { targetMargin: 0.12 } },
|
||||
});
|
||||
});
|
||||
|
||||
test('1688 history product keeps bounded identity and specification dimensions', () => {
|
||||
assert.equal(normalizeCalculatorType('alibaba1688'), 'alibaba1688');
|
||||
assert.equal(normalizeCalculatorType('unknown'), 'company');
|
||||
assert.deepEqual(sanitizeHistoryProduct({
|
||||
productId: ' 1073252781818 ',
|
||||
title: ' 测试商品 ',
|
||||
skuDimensions: [
|
||||
{ name: '颜色', values: ['原木色', '胡桃色'] },
|
||||
{ name: '尺寸', values: ['60cm'] },
|
||||
],
|
||||
}), {
|
||||
productId: '1073252781818',
|
||||
title: '测试商品',
|
||||
mainImage: '',
|
||||
sourceUrl: '',
|
||||
storeName: '',
|
||||
category: '',
|
||||
skuDimensions: [
|
||||
{ name: '颜色', values: ['原木色', '胡桃色'] },
|
||||
{ name: '尺寸', values: ['60cm'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
@ -1,17 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
DEFAULT_BOOTSTRAP_ENDPOINT,
|
||||
normalizeEndpoint,
|
||||
} = require('../src/services/remoteEnrollmentService');
|
||||
|
||||
test('remote enrollment defaults to the managed HTTPS endpoint', () => {
|
||||
assert.equal(normalizeEndpoint(''), DEFAULT_BOOTSTRAP_ENDPOINT);
|
||||
assert.equal(normalizeEndpoint('https://ck.yydd.ai:8443/v1/enroll'), 'https://ck.yydd.ai:8443/v1/enroll');
|
||||
});
|
||||
|
||||
test('remote enrollment refuses insecure or credential-bearing endpoints', () => {
|
||||
assert.throws(() => normalizeEndpoint('http://ck.yydd.ai/v1/enroll'), /HTTPS/u);
|
||||
assert.throws(() => normalizeEndpoint('https://user:secret@ck.yydd.ai/v1/enroll'), /HTTPS/u);
|
||||
});
|
||||
@ -1,31 +1,14 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
pruneRetiredSidebarConfig,
|
||||
cleanupRetiredFeatureSettings,
|
||||
isRetiredDatabaseConfigName,
|
||||
isRetiredSystemConfigKey,
|
||||
} = require('../src/services/retiredFeatures');
|
||||
const { pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings, isRetiredSystemConfigKey } = require('../src/services/retiredFeatures');
|
||||
|
||||
test('retired sidebar cleanup is recursive and idempotent', () => {
|
||||
const saved = JSON.stringify({ entries: [
|
||||
{ key: 'agent' },
|
||||
{ key: 'store-management', children: [{ key: 'store-checkup' }] },
|
||||
{ key: 'operation-tools', children: [{ key: 'pricing-calculator' }] },
|
||||
{ key: 'marketing-management', children: [{ key: 'marketing-events' }] },
|
||||
{ key: 'product-management', children: [{ key: 'product-analysis' }] },
|
||||
{ key: 'product-monitoring' },
|
||||
{ key: 'ops-b-incentive' },
|
||||
{ key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] },
|
||||
] });
|
||||
const saved = JSON.stringify({ entries: [{ key: 'agent' }, { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'settings', label: '管理设置' }] }] });
|
||||
const clean = pruneRetiredSidebarConfig(saved);
|
||||
assert.deepEqual(JSON.parse(clean).entries, [{ key: 'manage', children: [{ key: 'settings', label: '管理设置' }] }]);
|
||||
assert.equal(pruneRetiredSidebarConfig(clean), clean);
|
||||
assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid');
|
||||
assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('product_tag_excluded_categories'), true);
|
||||
assert.equal(isRetiredDatabaseConfigName('ai_buyer_show_database'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('site_name'), false);
|
||||
});
|
||||
|
||||
@ -33,12 +16,11 @@ test('upgrade removes only retired settings and permission rows', async () => {
|
||||
const calls = [];
|
||||
await cleanupRetiredFeatureSettings({ query: async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"},{"key":"settings"}]}' }] : [];
|
||||
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : [];
|
||||
} });
|
||||
const deletes = calls.filter((call) => call.sql.startsWith('DELETE'));
|
||||
assert.equal(deletes.length, 5);
|
||||
assert.equal(deletes.length, 4);
|
||||
assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0));
|
||||
assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 11);
|
||||
const update = calls.find((call) => call.sql.startsWith('UPDATE'));
|
||||
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'settings' }]);
|
||||
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]);
|
||||
});
|
||||
|
||||
@ -3,11 +3,14 @@ const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const express = require('express');
|
||||
|
||||
test('retired analysis endpoints return 404 while retained selection endpoint works', async () => {
|
||||
test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function (id, parent, ...rest) {
|
||||
if (parent?.filename.endsWith('/api/planningUpload.js')) {
|
||||
if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) };
|
||||
if (id.endsWith('/opsBIncentiveBoardService')) return {
|
||||
getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [],
|
||||
};
|
||||
if (id.endsWith('/alibaba1688ProductService')) return { getAlibaba1688Product: async (id) => ({ id }) };
|
||||
if (id.endsWith('/alibaba1688ProductEncodingRegistry')) return { attachEncodingDetail: async (value) => value };
|
||||
}
|
||||
@ -21,10 +24,10 @@ test('retired analysis endpoints return 404 while retained selection endpoint wo
|
||||
const server = await new Promise((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}/api/planning-upload`;
|
||||
for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans', '/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily']) {
|
||||
for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans']) {
|
||||
assert.equal((await fetch(base + path)).status, 404, path);
|
||||
}
|
||||
for (const path of ['/selection-fission/1688-products/123']) {
|
||||
for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) {
|
||||
const response = await fetch(base + path);
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal((await response.json()).success, true);
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
# 生产部署(Docker)
|
||||
|
||||
生产实例使用 MySQL、Redis、Redis TLS 网关、Node.js 后端和部署注册服务容器。MySQL、后端与注册服务只监听内部网络或服务器回环地址;Nginx 暴露 `ck.yydd.ai:8443`,Redis TLS 网关暴露 `ck.yydd.ai:6380`。
|
||||
|
||||
服务器目录约定:
|
||||
|
||||
- `/opt/cangku/app`:仓库代码和前端构建结果
|
||||
- `/opt/cangku/shared`:权限为 `0700` 的环境配置
|
||||
- `/opt/cangku/data`:MySQL、Redis 和部署注册服务持久数据
|
||||
- `/www/wwwroot/cangku-acme`:Let's Encrypt HTTP-01 校验目录
|
||||
|
||||
真实密码、JWT 密钥、部署 Key 和第三方数据库配置不得写入仓库。`mysql.env`、`redis.env`、`backend.env` 和 `bootstrap.env` 只在服务器生成并保持 `0600` 权限。
|
||||
|
||||
SSH 隧道账号只接受由注册服务 CA 签发的短期证书,只允许转发到服务器 `127.0.0.1:3307`,并关闭终端、TTY、代理转发和密码认证。生产主机的 SSH 管理账号使用独立密钥,关闭密码登录。
|
||||
|
||||
Redis 原始端口不映射到宿主机,容器以镜像内置的非特权 `redis` 用户运行。公网 `6380` 只接受 TLS,注册服务为每台设备创建独立 ACL 账号;停用安装会删除该账号。ACL 保存在 Redis 数据卷的 `users.acl`,注册与撤销后立即执行 `ACL SAVE`,容器重启不会丢失设备账号。证书文件应归属 HAProxy 容器的 `99:99` 并保持 `0600`,续期后需重新生成 `/opt/cangku/shared/redis-tls.pem`、恢复该属主并重启 TLS 网关。
|
||||
@ -1,117 +0,0 @@
|
||||
name: cangku
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0.39
|
||||
container_name: cangku-mysql
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- /opt/cangku/shared/mysql.env
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
ports:
|
||||
- 127.0.0.1:3307:3306
|
||||
volumes:
|
||||
- /opt/cangku/data/mysql:/var/lib/mysql
|
||||
- ../../backend/database/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
|
||||
- ../../backend/database/summary_tables.sql:/docker-entrypoint-initdb.d/002-summary-tables.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
networks:
|
||||
- cangku-internal
|
||||
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
container_name: cangku-redis
|
||||
restart: unless-stopped
|
||||
user: "999:1000"
|
||||
env_file:
|
||||
- /opt/cangku/shared/redis.env
|
||||
command: ["sh", "-c", "if [ ! -s /data/users.acl ]; then printf 'user default on >%s ~* +@all\\n' \"$$REDIS_PASSWORD\" > /data/users.acl; fi; exec redis-server --appendonly yes --save 60 1000 --aclfile /data/users.acl"]
|
||||
volumes:
|
||||
- /opt/cangku/data/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "REDISCLI_AUTH=$$REDIS_PASSWORD redis-cli ping | grep -q PONG"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
networks:
|
||||
- cangku-internal
|
||||
|
||||
redis-gateway:
|
||||
image: haproxy:3.0-alpine
|
||||
container_name: cangku-redis-gateway
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 6380:6380
|
||||
volumes:
|
||||
- ./haproxy-redis.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
|
||||
- /opt/cangku/shared/redis-tls.pem:/usr/local/etc/haproxy/certs/redis.pem:ro,z
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- cangku-internal
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../../backend
|
||||
dockerfile: Dockerfile.production
|
||||
container_name: cangku-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- /opt/cangku/shared/backend.env
|
||||
ports:
|
||||
- 127.0.0.1:4090:4090
|
||||
volumes:
|
||||
- /opt/cangku/data/backend:/app/runtime
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const s=require('node:net').connect(4090,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(3000,()=>process.exit(1));s.on('error',()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 18
|
||||
start_period: 30s
|
||||
networks:
|
||||
- cangku-internal
|
||||
|
||||
bootstrap:
|
||||
build:
|
||||
context: ../../remote-access/server
|
||||
dockerfile: Dockerfile.production
|
||||
container_name: cangku-bootstrap
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- /opt/cangku/shared/bootstrap.env
|
||||
ports:
|
||||
- 127.0.0.1:4092:4092
|
||||
volumes:
|
||||
- /opt/cangku/data/bootstrap:/var/lib/cangku-bootstrap
|
||||
- /etc/ssh/ssh_host_ed25519_key.pub:/etc/ssh/ssh_host_ed25519_key.pub:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4092/health', timeout=3)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
networks:
|
||||
- cangku-internal
|
||||
|
||||
networks:
|
||||
cangku-internal:
|
||||
name: cangku-internal
|
||||
@ -1,16 +0,0 @@
|
||||
global
|
||||
log stdout format raw local0
|
||||
|
||||
defaults
|
||||
log global
|
||||
mode tcp
|
||||
timeout connect 5s
|
||||
timeout client 1h
|
||||
timeout server 1h
|
||||
|
||||
frontend redis_tls
|
||||
bind *:6380 ssl crt /usr/local/etc/haproxy/certs/redis.pem ssl-min-ver TLSv1.2
|
||||
default_backend redis_plain
|
||||
|
||||
backend redis_plain
|
||||
server redis redis:6379 check
|
||||
@ -1,69 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name ck.yydd.ai;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /www/wwwroot/cangku-acme;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 308 https://$host:8443$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8443 ssl http2;
|
||||
server_name ck.yydd.ai;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/ck.yydd.ai/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/ck.yydd.ai/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:CangkuTLS:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
root /opt/cangku/app/frontend/dist;
|
||||
index index.html;
|
||||
client_max_body_size 100m;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://127.0.0.1:4090/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location = /v1/enroll {
|
||||
limit_except POST { deny all; }
|
||||
proxy_pass http://127.0.0.1:4092/v1/enroll;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /cubejs-api/ {
|
||||
proxy_pass http://127.0.0.1:4090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@ -143,6 +143,18 @@ export const testSelectionImageGenerationSettings = () => {
|
||||
|
||||
|
||||
|
||||
// 商品标签配置(规模标签 / 利润标签 / SABC 映射)
|
||||
export const getProductTagConfig = () => {
|
||||
return request.get('/api/admin/product-tag-config');
|
||||
};
|
||||
|
||||
export const updateProductTagConfig = (data) => {
|
||||
return request.put('/api/admin/product-tag-config', data);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
export const getAuthConfig = () => {
|
||||
return request.get('/api/admin/auth-config');
|
||||
};
|
||||
|
||||
74
frontend/src/api/aiBuyerShow.js
Normal file
74
frontend/src/api/aiBuyerShow.js
Normal file
@ -0,0 +1,74 @@
|
||||
import request from './request';
|
||||
|
||||
const AI_BUYER_SHOW_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
export const getAiBuyerShowRuntime = () => (
|
||||
request.get('/api/ai-buyer-show/runtime', { timeout: 30000 })
|
||||
);
|
||||
|
||||
export const previewAiBuyerShowPrompt = (data) => (
|
||||
request.post('/api/ai-buyer-show/prompt-preview', data, { timeout: 30000 })
|
||||
);
|
||||
|
||||
export const generateAiBuyerShowReviews = ({ values = {}, productImages = [] }) => {
|
||||
const formData = new FormData();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
formData.append(key, Array.isArray(value) ? value.join(',') : value);
|
||||
}
|
||||
});
|
||||
productImages.forEach((file) => {
|
||||
const rawFile = file.originFileObj || file;
|
||||
if (rawFile instanceof Blob) formData.append('productImages', rawFile);
|
||||
});
|
||||
return request.post('/api/ai-buyer-show/reviews', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: AI_BUYER_SHOW_TIMEOUT_MS,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
};
|
||||
|
||||
export const createAiBuyerShowJob = ({ values = {}, productImages = [], sceneReferenceImages = [] }) => {
|
||||
const formData = new FormData();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
productImages.forEach((file) => {
|
||||
const rawFile = file.originFileObj || file;
|
||||
if (rawFile instanceof Blob) formData.append('productImages', rawFile);
|
||||
});
|
||||
sceneReferenceImages.forEach((file) => {
|
||||
const rawFile = file.originFileObj || file;
|
||||
if (rawFile instanceof Blob) formData.append('sceneReferenceImages', rawFile);
|
||||
});
|
||||
|
||||
return request.post('/api/ai-buyer-show/jobs', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: AI_BUYER_SHOW_TIMEOUT_MS,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
};
|
||||
|
||||
export const getAiBuyerShowJobs = (params) => (
|
||||
request.get('/api/ai-buyer-show/jobs', { params, timeout: 30000 })
|
||||
);
|
||||
|
||||
export const getAiBuyerShowHistory = (params) => (
|
||||
request.get('/api/ai-buyer-show/history', { params, timeout: 30000 })
|
||||
);
|
||||
|
||||
export const getAiBuyerShowHistoryDetail = (id) => (
|
||||
request.get(`/api/ai-buyer-show/history/${id}`, { timeout: 30000 })
|
||||
);
|
||||
|
||||
export const getAiBuyerShowJob = (jobId) => (
|
||||
request.get(`/api/ai-buyer-show/jobs/${jobId}`, { timeout: 30000 })
|
||||
);
|
||||
|
||||
export const getAiBuyerShowQueueStats = (params) => (
|
||||
request.get('/api/ai-buyer-show/queue-stats', { params, timeout: 30000 })
|
||||
);
|
||||
44
frontend/src/api/csQuality.js
Normal file
44
frontend/src/api/csQuality.js
Normal file
@ -0,0 +1,44 @@
|
||||
import request from './request';
|
||||
|
||||
export const getCsQualityDataMeta = () => (
|
||||
request.get('/api/cs-quality/data-meta', { timeout: 120000 })
|
||||
);
|
||||
|
||||
export const getCsQualityGoods = (params) => (
|
||||
request.get('/api/cs-quality/goods', { params, timeout: 120000 })
|
||||
);
|
||||
|
||||
export const getCsQualityCustomers = (params) => (
|
||||
request.get('/api/cs-quality/customers', { params, timeout: 120000 })
|
||||
);
|
||||
|
||||
export const getCsQualityMessages = (params) => (
|
||||
request.get('/api/cs-quality/messages', { params, timeout: 120000 })
|
||||
);
|
||||
|
||||
export const getCsQualityProductProfile = (goodsId, params) => (
|
||||
request.get(`/api/cs-quality/goods/${encodeURIComponent(goodsId)}/profile`, { params, timeout: 120000 })
|
||||
);
|
||||
|
||||
export const runCsQualityAiAnalysis = (data) => (
|
||||
request.post('/api/cs-quality/analysis', data, { timeout: 900000 })
|
||||
);
|
||||
|
||||
export const startCsQualityAiAnalysis = (data) => (
|
||||
request.post('/api/cs-quality/analysis-jobs', data, { timeout: 120000, silentError: true })
|
||||
);
|
||||
|
||||
export const getCsQualityAiAnalysisJob = (analysisId) => (
|
||||
request.get(`/api/cs-quality/analysis-jobs/${encodeURIComponent(analysisId)}`, {
|
||||
timeout: 30000,
|
||||
silentError: true,
|
||||
})
|
||||
);
|
||||
|
||||
export const getCsQualityAnalysisHistory = (params) => (
|
||||
request.get('/api/cs-quality/analysis-history', { params, timeout: 120000 })
|
||||
);
|
||||
|
||||
export const getCsQualityAnalysisHistoryDetail = (id) => (
|
||||
request.get(`/api/cs-quality/analysis-history/${encodeURIComponent(id)}`, { timeout: 120000 })
|
||||
);
|
||||
@ -1,16 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
let dashboardMetaInFlight = null;
|
||||
|
||||
export const getDashboardMeta = () => {
|
||||
if (!dashboardMetaInFlight) {
|
||||
dashboardMetaInFlight = request.get('/api/dashboard/meta').finally(() => {
|
||||
dashboardMetaInFlight = null;
|
||||
});
|
||||
}
|
||||
return dashboardMetaInFlight;
|
||||
};
|
||||
|
||||
export const getDashboardSummary = (params, config = {}) => (
|
||||
request.get('/api/dashboard/summary', { ...config, params })
|
||||
);
|
||||
34
frontend/src/api/desktopClient.js
Normal file
34
frontend/src/api/desktopClient.js
Normal file
@ -0,0 +1,34 @@
|
||||
import request from './request';
|
||||
|
||||
export const getLatestDesktopRelease = (currentVersion, platform = 'win32', architecture = '') => (
|
||||
request.get('/api/desktop-client/latest', {
|
||||
params: {
|
||||
...(currentVersion ? { currentVersion } : {}),
|
||||
platform,
|
||||
...(architecture ? { architecture } : {}),
|
||||
},
|
||||
silentError: true,
|
||||
})
|
||||
);
|
||||
|
||||
export const getDesktopReleases = () => (
|
||||
request.get('/api/admin/desktop-releases', { silentError: true })
|
||||
);
|
||||
|
||||
export const publishDesktopRelease = ({ version, releaseNotes, installer, platform, architecture, onUploadProgress }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('version', version);
|
||||
formData.append('releaseNotes', releaseNotes || '');
|
||||
formData.append('platform', platform || 'win32');
|
||||
formData.append('architecture', architecture || (platform === 'darwin' ? 'arm64' : 'x64'));
|
||||
formData.append('installer', installer);
|
||||
|
||||
return request.post('/api/admin/desktop-releases', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30 * 60 * 1000,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
onUploadProgress,
|
||||
silentError: true,
|
||||
});
|
||||
};
|
||||
@ -4,14 +4,6 @@ export const getInstallStatus = () => {
|
||||
return request.get('/api/install/status');
|
||||
};
|
||||
|
||||
export const getRemoteBootstrapConfig = () => {
|
||||
return request.get('/api/install/remote-bootstrap');
|
||||
};
|
||||
|
||||
export const bootstrapRemoteInstallation = (data) => {
|
||||
return request.post('/api/install/remote-bootstrap', data);
|
||||
};
|
||||
|
||||
export const testInstallSystemDatabase = (data) => {
|
||||
return request.post('/api/install/system-database/test', data);
|
||||
};
|
||||
|
||||
17
frontend/src/api/marketingEvents.js
Normal file
17
frontend/src/api/marketingEvents.js
Normal file
@ -0,0 +1,17 @@
|
||||
import request from './request';
|
||||
|
||||
export const getMarketingEventsList = (params) => (
|
||||
request.get('/api/marketing-events/list', { params })
|
||||
);
|
||||
|
||||
export const getMarketingEventsLatestDate = () => (
|
||||
request.get('/api/marketing-events/latest-date')
|
||||
);
|
||||
|
||||
export const getMarketingEventsSummary = (params) => (
|
||||
request.get('/api/marketing-events/summary', { params })
|
||||
);
|
||||
|
||||
export const getMarketingEventsStatusCounts = () => (
|
||||
request.get('/api/marketing-events/status-counts')
|
||||
);
|
||||
@ -2,6 +2,21 @@ import request from './request';
|
||||
|
||||
const PLANNING_TABLE_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
export const getOpsBIncentiveBatches = () => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive/batches',
|
||||
{ timeout: 60 * 1000 }
|
||||
);
|
||||
|
||||
export const getOpsBIncentiveBoard = (params = {}) => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive',
|
||||
{ params, timeout: PLANNING_TABLE_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const getOpsBIncentiveDaily = (params = {}) => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive/daily',
|
||||
{ params, timeout: PLANNING_TABLE_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const getPlanningAlibaba1688ProductDetail = (productId) => {
|
||||
return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, {
|
||||
headers: {
|
||||
|
||||
79
frontend/src/api/pricingCalculator.js
Normal file
79
frontend/src/api/pricingCalculator.js
Normal file
@ -0,0 +1,79 @@
|
||||
import request from './request';
|
||||
|
||||
// 统一处理 { success, data, error } 包裹:业务错误抛出带友好文案的 Error,
|
||||
// 由页面以行内状态展示,避免 axios 默认的 "Request failed with status code xxx"。
|
||||
const unwrap = async (promise) => {
|
||||
try {
|
||||
const res = await promise;
|
||||
if (res && res.success === true) {
|
||||
return res.data;
|
||||
}
|
||||
throw new Error(res?.error || '请求失败,请稍后重试');
|
||||
} catch (error) {
|
||||
const friendly = error?.response?.data?.error;
|
||||
if (friendly) {
|
||||
throw new Error(friendly);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 业务错误(404/400)也以 resolve 返回,交给 unwrap 解析友好文案
|
||||
const tolerantConfig = { validateStatus: (status) => status < 500 };
|
||||
|
||||
export const fetchSpuSkus = (spuCode, params = {}) => unwrap(
|
||||
request.get(`/api/pricing-calculator/spu/${encodeURIComponent(spuCode)}`, {
|
||||
...tolerantConfig,
|
||||
params,
|
||||
})
|
||||
);
|
||||
|
||||
export const fetchProductCodeMeta = () => unwrap(
|
||||
request.get('/api/pricing-calculator/product-code/meta', tolerantConfig)
|
||||
);
|
||||
|
||||
export const savePricingHistory = (payload) => unwrap(
|
||||
request.post('/api/pricing-calculator/history', payload, tolerantConfig)
|
||||
);
|
||||
|
||||
export const fetchPricingHistoryList = (params) => unwrap(
|
||||
request.get('/api/pricing-calculator/history', { ...tolerantConfig, params })
|
||||
);
|
||||
|
||||
export const fetchPricingHistoryDetail = (id) => unwrap(
|
||||
request.get(`/api/pricing-calculator/history/${id}`, tolerantConfig)
|
||||
);
|
||||
|
||||
export const deletePricingHistory = (id) => unwrap(
|
||||
request.delete(`/api/pricing-calculator/history/${id}`, tolerantConfig)
|
||||
);
|
||||
|
||||
// 导出为 xlsx:后端生成文件流。注意 request 的响应拦截器已把返回值解包为响应体本身,
|
||||
// 成功时是 xlsx 的 Blob;业务错误(400/500)时后端返回 JSON,此处也是 Blob(type 为 application/json),需解析错误文案
|
||||
export const exportPricingTable = async (payload) => {
|
||||
const blob = await request.post('/api/pricing-calculator/export', payload, {
|
||||
responseType: 'blob',
|
||||
validateStatus: () => true, // 状态码已被拦截器抹掉,靠 Blob 类型区分成功与错误
|
||||
});
|
||||
if (blob instanceof Blob && blob.type.includes('json')) {
|
||||
let friendly = '导出失败,请稍后重试';
|
||||
try {
|
||||
const parsed = JSON.parse(await blob.text());
|
||||
friendly = parsed?.error || friendly;
|
||||
} catch { /* 保持默认文案 */ }
|
||||
throw new Error(friendly);
|
||||
}
|
||||
return blob;
|
||||
};
|
||||
|
||||
// 个性化默认值:读取/保存当前登录用户的配置(无配置时 prefs 为 null,前端回退系统默认)
|
||||
export const fetchPricingPrefs = (profile = 'company') => unwrap(
|
||||
request.get('/api/pricing-calculator/prefs', {
|
||||
...tolerantConfig,
|
||||
params: { profile },
|
||||
})
|
||||
);
|
||||
|
||||
export const savePricingPrefs = (prefs, profile = 'company') => unwrap(
|
||||
request.put('/api/pricing-calculator/prefs', { prefs, profile }, tolerantConfig)
|
||||
);
|
||||
42
frontend/src/api/productAnalysis.js
Normal file
42
frontend/src/api/productAnalysis.js
Normal file
@ -0,0 +1,42 @@
|
||||
import request from './request';
|
||||
|
||||
let productAnalysisMetaInFlight = null;
|
||||
const productAnalysisProductsInFlight = new Map();
|
||||
|
||||
export const getProductAnalysisMeta = () => {
|
||||
if (!productAnalysisMetaInFlight) {
|
||||
productAnalysisMetaInFlight = request.get('/api/product-analysis/meta')
|
||||
.finally(() => {
|
||||
productAnalysisMetaInFlight = null;
|
||||
});
|
||||
}
|
||||
return productAnalysisMetaInFlight;
|
||||
};
|
||||
|
||||
export const getProductAnalysisSignalThresholds = () => (
|
||||
request.get('/api/product-analysis/signal-thresholds', { silentError: true })
|
||||
);
|
||||
|
||||
export const saveProductAnalysisSignalThresholds = (thresholds) => (
|
||||
request.put('/api/product-analysis/signal-thresholds', { thresholds }, { silentError: true })
|
||||
);
|
||||
|
||||
export const getDevelopmentDashboard = (params, config = {}) => (
|
||||
request.get('/api/product-analysis/dashboard', { ...config, params })
|
||||
);
|
||||
|
||||
export const getProductAnalysisProducts = (params) => {
|
||||
const requestKey = JSON.stringify(params || {});
|
||||
if (!productAnalysisProductsInFlight.has(requestKey)) {
|
||||
const inFlight = request.get('/api/product-analysis/products', { params })
|
||||
.finally(() => {
|
||||
productAnalysisProductsInFlight.delete(requestKey);
|
||||
});
|
||||
productAnalysisProductsInFlight.set(requestKey, inFlight);
|
||||
}
|
||||
return productAnalysisProductsInFlight.get(requestKey);
|
||||
};
|
||||
|
||||
export const getProductAnalysisTab = (goodsId, tabKey, params) => (
|
||||
request.get(`/api/product-analysis/products/${encodeURIComponent(goodsId)}/tabs/${tabKey}`, { params })
|
||||
);
|
||||
34
frontend/src/api/productMonitoring.js
Normal file
34
frontend/src/api/productMonitoring.js
Normal file
@ -0,0 +1,34 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const META_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
// 明细列表(公司标签 / 部门标签 / 负责人 / 汇总)
|
||||
export const getProductMonitoringList = (params = {}) => request.get(
|
||||
'/api/product-monitoring/list',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 筛选下拉选项(品类 / 供应商 / 仓库 / 采购负责人 / 销售状态)
|
||||
export const getProductMonitoringFilters = () => request.get(
|
||||
'/api/product-monitoring/filters',
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||
export const getProductMonitoringOperators = (sku) => request.get(
|
||||
'/api/product-monitoring/operators',
|
||||
{ params: { sku }, timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 部门标签(G-S/G-A/G-B/G-C)+ 部门 SKU 负责人配置
|
||||
export const getDepartmentTagConfig = () => request.get(
|
||||
'/api/product-monitoring/department-config',
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const saveDepartmentTagConfig = (payload = {}) => request.put(
|
||||
'/api/product-monitoring/department-config',
|
||||
payload,
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
17
frontend/src/api/promotionModule.js
Normal file
17
frontend/src/api/promotionModule.js
Normal file
@ -0,0 +1,17 @@
|
||||
import request from './request';
|
||||
|
||||
export const getPromotionModuleList = (params) => (
|
||||
request.get('/api/promotion-module/list', { params })
|
||||
);
|
||||
|
||||
export const getPromotionModuleLatestDate = () => (
|
||||
request.get('/api/promotion-module/latest-date')
|
||||
);
|
||||
|
||||
export const getPromotionModuleSummary = (params) => (
|
||||
request.get('/api/promotion-module/summary', { params })
|
||||
);
|
||||
|
||||
export const getPromotionModuleShops = () => (
|
||||
request.get('/api/promotion-module/shops')
|
||||
);
|
||||
23
frontend/src/api/storeCheckup.js
Normal file
23
frontend/src/api/storeCheckup.js
Normal file
@ -0,0 +1,23 @@
|
||||
import request from './request';
|
||||
|
||||
export const getStoreCheckupMeta = () => request.get('/api/store-checkup/meta');
|
||||
|
||||
export const getStoreCheckupOverview = (params) => (
|
||||
request.get('/api/store-checkup/overview', { params })
|
||||
);
|
||||
|
||||
export const getStoreCheckupHomeSummary = (params, config = {}) => (
|
||||
request.get('/api/store-checkup/home-summary', { ...config, params })
|
||||
);
|
||||
|
||||
export const getStoreCheckupHomeReviews = (params, config = {}) => (
|
||||
request.get('/api/store-checkup/home-reviews', { ...config, params })
|
||||
);
|
||||
|
||||
export const getStoreCheckupHomeCompensation = (params, config = {}) => (
|
||||
request.get('/api/store-checkup/home-compensation', { ...config, params })
|
||||
);
|
||||
|
||||
export const getStoreCheckupTab = (tabKey, params) => (
|
||||
request.get(`/api/store-checkup/tabs/${tabKey}`, { params })
|
||||
);
|
||||
@ -2,12 +2,45 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
collapsedByDefault: false,
|
||||
darkModeByDefault: false,
|
||||
entries: [
|
||||
{ key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
|
||||
{ key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
|
||||
{ key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
|
||||
{
|
||||
key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
|
||||
{ key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'store-management', type: 'group', label: '店铺管理', icon: 'shop', visible: true, order: 80, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'store-checkup', label: '店铺体检', icon: 'check-circle', routeKey: 'store-checkup', visible: true, order: 10 },
|
||||
{ key: 'review-analysis', label: '复盘分析', icon: 'history', routeKey: 'review-analysis', visible: true, order: 20 },
|
||||
{ key: 'customer-service-checkup', label: '客服质检', icon: 'customer-service', routeKey: 'customer-service-checkup', visible: true, order: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'operation-tools', type: 'group', label: '运营工具', icon: 'tool', visible: true, order: 90, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'pricing-calculator', label: '上架测算', icon: 'calculator', routeKey: 'pricing-calculator', visible: true, order: 5 },
|
||||
{ key: 'desktop-client-download', label: '客户端下载', icon: 'desktop', routeKey: 'desktop-client-download', visible: true, order: 8 },
|
||||
{ key: 'ai-buyer-show', label: 'AI买家秀', icon: 'image', routeKey: 'ai-buyer-show', visible: true, order: 10 },
|
||||
{ key: 'ai-seeding-video', label: 'AI种草视频', icon: 'video', routeKey: 'ai-seeding-video', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
|
||||
{
|
||||
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
|
||||
children: [
|
||||
{ key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
|
||||
{ key: 'desktop-client-releases', label: '客户端发布', icon: 'desktop', routeKey: 'desktop-client-releases', visible: true, order: 20, requiresAdmin: true },
|
||||
{ key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
|
||||
{ key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
|
||||
],
|
||||
@ -30,6 +63,28 @@ const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
|
||||
// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
|
||||
const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
|
||||
const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
|
||||
const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
|
||||
// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
|
||||
const extractPromotedTopLevelEntries = (entries = []) => {
|
||||
const promoted = [];
|
||||
const nextEntries = entries.map((entry) => {
|
||||
if (!Array.isArray(entry?.children)) return entry;
|
||||
const children = [];
|
||||
entry.children.forEach((child) => {
|
||||
if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
|
||||
promoted.push(child);
|
||||
} else {
|
||||
children.push(child);
|
||||
}
|
||||
});
|
||||
return children.length === entry.children.length ? entry : { ...entry, children };
|
||||
});
|
||||
return { nextEntries, promoted };
|
||||
};
|
||||
|
||||
const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
|
||||
const configuredKeys = new Set();
|
||||
@ -45,6 +100,18 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
|
||||
merged.requiresAdmin = defaultEntry.requiresAdmin;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
|
||||
) {
|
||||
merged.label = defaultEntry.label;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
|
||||
) {
|
||||
merged.order = defaultEntry.order;
|
||||
}
|
||||
const hasDefaultChildren = Array.isArray(defaultEntry.children);
|
||||
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
|
||||
|
||||
@ -81,10 +148,15 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
|
||||
const mergeSidebarConfigWithDefaults = (config) => {
|
||||
const defaults = cloneDefaultConfig();
|
||||
const nestedEntries = (config?.entries || []);
|
||||
const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
|
||||
const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
|
||||
const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...config,
|
||||
entries: mergeSidebarEntries(defaults.entries, config?.entries || []),
|
||||
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -2,25 +2,33 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
CalculatorOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloudOutlined,
|
||||
CustomerServiceOutlined,
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
DownOutlined,
|
||||
ExperimentOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
HistoryOutlined,
|
||||
LineChartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MessageOutlined,
|
||||
MoonOutlined,
|
||||
PictureOutlined,
|
||||
RightOutlined,
|
||||
RobotOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
ShoppingOutlined,
|
||||
TableOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
VideoCameraOutlined,
|
||||
SunOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Dropdown, Switch } from 'antd';
|
||||
@ -56,24 +64,49 @@ const readSidebarDarkModePreference = () => {
|
||||
|
||||
const SIDEBAR_ICON_MAP = {
|
||||
appstore: AppstoreOutlined,
|
||||
calculator: CalculatorOutlined,
|
||||
'check-circle': CheckCircleOutlined,
|
||||
cloud: CloudOutlined,
|
||||
'customer-service': CustomerServiceOutlined,
|
||||
dashboard: DashboardOutlined,
|
||||
desktop: DesktopOutlined,
|
||||
experiment: ExperimentOutlined,
|
||||
history: HistoryOutlined,
|
||||
image: PictureOutlined,
|
||||
'line-chart': LineChartOutlined,
|
||||
message: MessageOutlined,
|
||||
projection: FundProjectionScreenOutlined,
|
||||
robot: RobotOutlined,
|
||||
setting: SettingOutlined,
|
||||
shop: ShopOutlined,
|
||||
shopping: ShoppingOutlined,
|
||||
table: TableOutlined,
|
||||
team: TeamOutlined,
|
||||
tool: ToolOutlined,
|
||||
video: VideoCameraOutlined,
|
||||
};
|
||||
|
||||
const SIDEBAR_ROUTE_MAP = {
|
||||
'dashboard-preview': { path: '/planning/dev-style-preview' },
|
||||
'marketing-events': {
|
||||
path: '/planning/operation/marketing-events',
|
||||
activePath: '/planning/operation/marketing-events',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'promotion-module': { path: '/planning/operation/promotion-module' },
|
||||
'pricing-calculator': { path: '/planning/operation/pricing-calculator' },
|
||||
'product-monitoring': { path: '/planning/operation/product-monitoring' },
|
||||
'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
|
||||
'desktop-client-download': { path: '/planning/operation/desktop-client-download' },
|
||||
'product-analysis': { path: '/planning/operation/product-analysis' },
|
||||
'store-checkup': { path: '/planning/operation/store-checkup' },
|
||||
'review-analysis': { path: '/planning/operation/review-analysis' },
|
||||
'customer-service-checkup': { path: '/planning/operation/customer-service-checkup' },
|
||||
'ai-buyer-show': { path: '/planning/operation/ai-buyer-show' },
|
||||
'ai-seeding-video': { path: '/planning/operation/ai-seeding-video' },
|
||||
'work-orders': { path: '/planning/operation/work-orders' },
|
||||
settings: { path: '/settings' },
|
||||
'desktop-client-releases': { path: '/settings/desktop-client-releases' },
|
||||
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
||||
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
||||
};
|
||||
|
||||
1093
frontend/src/pages/AiBuyerShow/index.jsx
Normal file
1093
frontend/src/pages/AiBuyerShow/index.jsx
Normal file
File diff suppressed because it is too large
Load Diff
688
frontend/src/pages/AiBuyerShow/style.css
Normal file
688
frontend/src/pages/AiBuyerShow/style.css
Normal file
@ -0,0 +1,688 @@
|
||||
.ai-buyer-show-page {
|
||||
min-height: 100%;
|
||||
padding: 16px 18px 28px;
|
||||
background: var(--sycm-bg-page-surface, #f5f7fa);
|
||||
}
|
||||
|
||||
.ai-buyer-show-header {
|
||||
position: sticky;
|
||||
z-index: 8;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: -16px -18px 16px;
|
||||
border-bottom: 1px solid var(--sycm-border-light, #e5e7eb);
|
||||
padding: 10px 18px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.ai-buyer-show-header__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-header__copy h1 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong, #111827);
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-header__copy span,
|
||||
.ai-buyer-show-queue {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-buyer-show-header__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-search {
|
||||
width: min(340px, 30vw);
|
||||
}
|
||||
|
||||
.ai-buyer-show-library {
|
||||
min-height: calc(100vh - 130px);
|
||||
}
|
||||
|
||||
.ai-buyer-show-selection-bar {
|
||||
position: sticky;
|
||||
z-index: 7;
|
||||
top: 58px;
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: -4px 0 12px;
|
||||
border: 1px solid var(--sycm-border-light, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.ai-buyer-show-selection-bar > span {
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-selection-bar .ant-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ai-buyer-show-state {
|
||||
display: grid;
|
||||
min-height: 420px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ai-buyer-show-waterfall {
|
||||
column-count: 5;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin: 0 0 12px;
|
||||
break-inside: avoid;
|
||||
border: 1px solid rgba(15, 23, 42, 0.07);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
vertical-align: top;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card.is-selected {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.14);
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__select {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 1px 5px rgba(15, 23, 42, 0.18);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__select .ant-checkbox-wrapper {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card:hover {
|
||||
border-color: rgba(22, 119, 255, 0.28);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.09);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: #eef1f5;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__preview img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 140px;
|
||||
max-height: 360px;
|
||||
object-fit: cover;
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card:hover .ai-buyer-show-card__preview img {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__count,
|
||||
.ai-buyer-show-card__status {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
background: rgba(17, 24, 39, 0.76);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__count {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__status {
|
||||
left: 44px;
|
||||
background: rgba(212, 136, 6, 0.88);
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__body {
|
||||
padding: 10px 11px 11px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__title strong {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--sycm-text-strong, #111827);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__title span {
|
||||
flex: 0 0 auto;
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__body p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 6px 0 9px;
|
||||
color: var(--sycm-text-secondary, #596579);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 7px;
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card__meta span {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-buyer-show-load-more {
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-create-drawer .ant-drawer-header {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-create-drawer .ant-drawer-body {
|
||||
padding: 14px 18px 24px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.ai-buyer-show-create-drawer .ant-drawer-footer {
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-current-job {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #d9e8ff;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.ai-buyer-show-current-job > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-current-job strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-section {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--sycm-border-light, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-section h3 {
|
||||
margin: 0 0 11px;
|
||||
color: var(--sycm-text-strong, #111827);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-heading h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-heading span {
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-list {
|
||||
display: flex;
|
||||
max-height: 330px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 16px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
border: 1px solid var(--sycm-border-light, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item:hover {
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item.is-selected {
|
||||
border-color: #1677ff;
|
||||
background: #f3f8ff;
|
||||
box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: 3px;
|
||||
border: 1px solid #b8c0cc;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item.is-selected .ai-buyer-show-review-radio {
|
||||
border: 4px solid #1677ff;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item > span:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item strong {
|
||||
color: var(--sycm-text-strong, #111827);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item__heading {
|
||||
display: flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item__heading .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item em {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-item p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--sycm-text-secondary, #596579);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-images .ant-image {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 1px solid var(--sycm-border-light, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-images img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-images__empty {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-section .ant-form-item {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-section .ant-form-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-section .ant-form-item-label {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-section .ant-form-item-label > label {
|
||||
height: 20px;
|
||||
color: var(--sycm-text-secondary, #596579);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0 10px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-fixed-setting {
|
||||
min-width: 0;
|
||||
padding-bottom: 11px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-fixed-setting > span {
|
||||
display: block;
|
||||
height: 24px;
|
||||
color: var(--sycm-text-secondary, #596579);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-fixed-setting > strong {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--sycm-border-light, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
background: #f7f9fc;
|
||||
color: var(--sycm-text-strong, #111827);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-buyer-show-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-upload-grid .ant-form-item {
|
||||
min-width: 0;
|
||||
border: 1px dashed #d7dce5;
|
||||
border-radius: 6px;
|
||||
padding: 9px;
|
||||
background: #fafbfd;
|
||||
}
|
||||
|
||||
.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list-picture-card .ant-upload-list-item-container,
|
||||
.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload.ant-upload-select {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ai-buyer-show-preview-modal .ant-modal-body {
|
||||
max-height: min(76vh, 760px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-buyer-show-history-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-history-toolbar .ant-input-affix-wrapper {
|
||||
width: min(380px, 100%);
|
||||
}
|
||||
|
||||
.ai-buyer-show-preview-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--sycm-text-tertiary, #8a94a6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-preview-review {
|
||||
margin: 0 0 12px;
|
||||
border-left: 3px solid #d7e6ff;
|
||||
padding: 7px 10px;
|
||||
background: #f7f9fc;
|
||||
color: var(--sycm-text-secondary, #596579);
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-preview-grid .ant-image {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 1px solid var(--sycm-border-light, #e5e7eb);
|
||||
border-radius: 5px;
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.ai-buyer-show-preview-grid img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 1600px) {
|
||||
.ai-buyer-show-waterfall {
|
||||
column-count: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.ai-buyer-show-waterfall {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.ai-buyer-show-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-header {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
margin: -12px -12px 12px;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-header__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-buyer-show-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ai-buyer-show-header__actions > .ant-btn:last-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ai-buyer-show-selection-bar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.ai-buyer-show-history-drawer .ant-drawer-content-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ai-buyer-show-waterfall {
|
||||
column-count: 2;
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-card {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-buyer-show-create-drawer .ant-drawer-content-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.ai-buyer-show-header__copy {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ai-buyer-show-waterfall {
|
||||
column-count: 1;
|
||||
}
|
||||
|
||||
.ai-buyer-show-form-grid,
|
||||
.ai-buyer-show-upload-grid,
|
||||
.ai-buyer-show-preview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ai-buyer-show-settings-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ai-buyer-show-review-images {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
378
frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx
Normal file
378
frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx
Normal file
@ -0,0 +1,378 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Button, DatePicker, Drawer, Image, Progress, Spin, Tag, Typography, message } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
getCsQualityAiAnalysisJob,
|
||||
getCsQualityProductProfile,
|
||||
startCsQualityAiAnalysis,
|
||||
} from '../../api/csQuality';
|
||||
import AiQualityResult from './AiQualityResult';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Text, Title } = Typography;
|
||||
const ACTIVE_ANALYSIS_STATUSES = new Set(['queued', 'running']);
|
||||
const ACTIVE_JOB_STORAGE_PREFIX = 'cs-quality-active-job:';
|
||||
const PROGRESS_HINTS = {
|
||||
queued: ['正在分配分析任务', '任务状态会持续同步到当前页面'],
|
||||
preparing: ['正在确认商品信息和聊天周期', '正在检查本次质检所需的数据'],
|
||||
loading: ['正在统计客户数量与消息数量', '正在读取所选周期内的完整聊天记录'],
|
||||
grouping: ['正在按客户归整连续对话', '同一客户的上下文会保留在同一分析批次中'],
|
||||
batching: ['正在根据数据量安排分析批次', '批次准备完成后会逐批更新进度'],
|
||||
analyzing: [
|
||||
'正在识别买家诉求、高频问题和对应证据',
|
||||
'正在逐批检查售前咨询与售后反馈',
|
||||
'数据量较大时可能需要数分钟,任务仍会在后台继续运行',
|
||||
'完成后的结果会自动展示,并同步保存到质检历史记录',
|
||||
],
|
||||
merging: ['正在合并各批次发现的问题', '正在去重问题分类与聊天证据'],
|
||||
validating: ['正在复核问题分类和影响客户数', '正在校验诊断结果的完整性'],
|
||||
saving: ['结果已生成,正在写入质检历史记录', '保存完成后页面会自动展示诊断结果'],
|
||||
};
|
||||
|
||||
function unwrapResponse(response) {
|
||||
return response?.data?.data || response?.data || response;
|
||||
}
|
||||
|
||||
function sanitizeUserMessage(value, fallback) {
|
||||
return String(value || fallback || '').trim();
|
||||
}
|
||||
|
||||
function activeJobStorageKey(goodsId) {
|
||||
return `${ACTIVE_JOB_STORAGE_PREFIX}${goodsId || ''}`;
|
||||
}
|
||||
|
||||
function readActiveJob(goodsId) {
|
||||
try {
|
||||
const saved = JSON.parse(window.localStorage.getItem(activeJobStorageKey(goodsId)) || 'null');
|
||||
if (!saved?.analysisId || String(saved.goodsId) !== String(goodsId)) return null;
|
||||
return saved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistActiveJob(goodsId, job) {
|
||||
if (!goodsId || !job?.analysisId) return;
|
||||
const range = job.range || {};
|
||||
try {
|
||||
window.localStorage.setItem(activeJobStorageKey(goodsId), JSON.stringify({
|
||||
...job,
|
||||
goodsId: String(goodsId),
|
||||
startDate: range.startDate || job.startDate,
|
||||
endDate: range.endDate || job.endDate,
|
||||
}));
|
||||
} catch {
|
||||
// Progress polling still works when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveJob(goodsId, analysisId) {
|
||||
try {
|
||||
const key = activeJobStorageKey(goodsId);
|
||||
const saved = readActiveJob(goodsId);
|
||||
if (!saved || !analysisId || saved.analysisId === analysisId) window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore storage restrictions after the server-side task has completed.
|
||||
}
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds) {
|
||||
const seconds = Math.max(0, Number(totalSeconds) || 0);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainder = Math.floor(seconds % 60);
|
||||
if (hours) return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getDefaultRange() {
|
||||
const endDate = dayjs().subtract(1, 'day');
|
||||
return [endDate.subtract(29, 'day'), endDate];
|
||||
}
|
||||
|
||||
function normalizeRange(value) {
|
||||
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) return getDefaultRange();
|
||||
if (Math.abs(value[1].startOf('day').diff(value[0].startOf('day'), 'day')) > 29) return [value[1].subtract(29, 'day'), value[1]];
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 0) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '--';
|
||||
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
const AiQualityDrawer = ({ open, goods, onClose, onCompleted }) => {
|
||||
const [range, setRange] = useState(getDefaultRange);
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [analysisJob, setAnalysisJob] = useState(null);
|
||||
const [analysisResult, setAnalysisResult] = useState(null);
|
||||
const [analysisError, setAnalysisError] = useState('');
|
||||
const [pollWarning, setPollWarning] = useState('');
|
||||
const [hintIndex, setHintIndex] = useState(0);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const onCompletedRef = useRef(onCompleted);
|
||||
const completedAnalysisRef = useRef('');
|
||||
|
||||
useEffect(() => { onCompletedRef.current = onCompleted; }, [onCompleted]);
|
||||
|
||||
const analysisLoading = ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status);
|
||||
|
||||
const params = useMemo(() => ({
|
||||
startDate: range?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: range?.[1]?.format('YYYY-MM-DD'),
|
||||
}), [range]);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
if (!open || !goods?.goodsId || !params.startDate || !params.endDate) return;
|
||||
setProfileLoading(true);
|
||||
try {
|
||||
const response = await getCsQualityProductProfile(goods.goodsId, params);
|
||||
const payload = response?.data?.data || response?.data || response;
|
||||
setProfile(payload);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || error.message || '加载商品表现失败');
|
||||
setProfile(null);
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
}, [open, goods?.goodsId, params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const savedJob = readActiveJob(goods?.goodsId);
|
||||
const savedRange = savedJob?.startDate && savedJob?.endDate
|
||||
? [dayjs(savedJob.startDate), dayjs(savedJob.endDate)]
|
||||
: null;
|
||||
setRange(savedRange?.every((item) => item.isValid()) ? savedRange : getDefaultRange());
|
||||
setAnalysisJob(savedJob);
|
||||
setAnalysisResult(null);
|
||||
setAnalysisError('');
|
||||
setPollWarning('');
|
||||
completedAnalysisRef.current = '';
|
||||
}, [open, goods?.goodsId]);
|
||||
|
||||
useEffect(() => { loadProfile(); }, [loadProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analysisLoading) return undefined;
|
||||
const startedAt = Date.parse(analysisJob?.createdAt || '') || Date.now();
|
||||
const updateElapsed = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||
updateElapsed();
|
||||
const timer = window.setInterval(updateElapsed, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [analysisLoading, analysisJob?.createdAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analysisLoading) return undefined;
|
||||
setHintIndex(0);
|
||||
const timer = window.setInterval(() => setHintIndex((current) => current + 1), 8000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [analysisLoading, analysisJob?.progress?.stage]);
|
||||
|
||||
useEffect(() => {
|
||||
const analysisId = analysisJob?.analysisId;
|
||||
if (!analysisId || !ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status)) return undefined;
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
|
||||
const poll = async () => {
|
||||
let nextStatus = analysisJob.status;
|
||||
try {
|
||||
const payload = unwrapResponse(await getCsQualityAiAnalysisJob(analysisId));
|
||||
if (cancelled || !payload) return;
|
||||
nextStatus = payload.status;
|
||||
setAnalysisJob(payload);
|
||||
setPollWarning('');
|
||||
if (ACTIVE_ANALYSIS_STATUSES.has(payload.status)) {
|
||||
persistActiveJob(goods?.goodsId, payload);
|
||||
} else {
|
||||
clearActiveJob(goods?.goodsId, analysisId);
|
||||
}
|
||||
|
||||
if (payload.status === 'succeeded' && payload.result && completedAnalysisRef.current !== analysisId) {
|
||||
completedAnalysisRef.current = analysisId;
|
||||
setAnalysisResult(payload.result);
|
||||
setProfile((current) => current || {
|
||||
product: payload.result.product,
|
||||
metrics: payload.result.metrics,
|
||||
chatSummary: payload.result.chatSummary,
|
||||
});
|
||||
setAnalysisError('');
|
||||
message.success('AI质检完成');
|
||||
onCompletedRef.current?.(payload.result);
|
||||
} else if (payload.status === 'succeeded' && !payload.result) {
|
||||
setAnalysisError('质检任务已完成,但结果暂时无法读取,请在质检历史记录中查看');
|
||||
} else if (payload.status === 'failed') {
|
||||
setAnalysisError(sanitizeUserMessage(payload.errorMessage, 'AI质检未完成,请稍后重试'));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setPollWarning('进度同步暂时中断,系统正在自动重试,后台任务不会停止');
|
||||
}
|
||||
|
||||
if (!cancelled && ACTIVE_ANALYSIS_STATUSES.has(nextStatus)) {
|
||||
timer = window.setTimeout(poll, 2500);
|
||||
}
|
||||
};
|
||||
|
||||
timer = window.setTimeout(poll, 700);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
}, [analysisJob?.analysisId, analysisJob?.status, goods?.goodsId]);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!goods?.goodsId || !params.startDate || !params.endDate) return;
|
||||
const createdAt = new Date().toISOString();
|
||||
setAnalysisJob({
|
||||
status: 'queued',
|
||||
createdAt,
|
||||
range: params,
|
||||
progress: { stage: 'queued', current: 0, total: 0, percent: 0, message: '正在创建质检任务' },
|
||||
});
|
||||
setAnalysisResult(null);
|
||||
setAnalysisError('');
|
||||
setPollWarning('');
|
||||
completedAnalysisRef.current = '';
|
||||
try {
|
||||
const response = await startCsQualityAiAnalysis({
|
||||
goodsId: goods.goodsId,
|
||||
goodsName: goods.goodsName,
|
||||
shopName: goods.shopName,
|
||||
hdThumbUrl: goods.hdThumbUrl,
|
||||
...params,
|
||||
});
|
||||
const payload = unwrapResponse(response);
|
||||
setAnalysisJob(payload);
|
||||
persistActiveJob(goods.goodsId, payload);
|
||||
} catch (error) {
|
||||
setAnalysisJob(null);
|
||||
setAnalysisError(sanitizeUserMessage(error.response?.data?.error || error.message, '创建AI质检任务失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const product = { ...goods, ...(profile?.product || {}) };
|
||||
const metrics = profile?.metrics || {};
|
||||
const chatSummary = profile?.chatSummary || {};
|
||||
const progress = analysisJob?.progress || {};
|
||||
const progressPercent = Math.min(100, Math.max(0, Number(progress.percent || 0)));
|
||||
const progressHints = PROGRESS_HINTS[progress.stage] || PROGRESS_HINTS.analyzing;
|
||||
const progressHint = pollWarning || progressHints[hintIndex % progressHints.length];
|
||||
const batchProgress = Number(progress.total) > 0
|
||||
? `已完成 ${Number(progress.current) || 0} / ${Number(progress.total)} 批`
|
||||
: '正在准备分析数据';
|
||||
const metricItems = [
|
||||
['聊天客户', formatNumber(chatSummary.userCount)],
|
||||
['聊天消息', formatNumber(chatSummary.messageCount)],
|
||||
['商品访客', formatNumber(metrics.goodsUv)],
|
||||
['商品浏览', formatNumber(metrics.goodsPv)],
|
||||
['支付金额', `¥${formatNumber(metrics.payOrdrAmt, 2)}`],
|
||||
['支付件数', formatNumber(metrics.payOrdrGoodsQty)],
|
||||
['商品转化率', `${formatNumber(metrics.goodsVcr, 2)}%`],
|
||||
['支付转化率', `${formatNumber(metrics.payOrdrRto, 2)}%`],
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="商品聊天 AI 质检"
|
||||
width={940}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
destroyOnHidden
|
||||
extra={(
|
||||
<Button type="primary" icon={<SafetyCertificateOutlined />} loading={analysisLoading} onClick={handleAnalyze}>
|
||||
{analysisLoading ? '质检进行中' : '开始AI质检'}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<Spin spinning={profileLoading}>
|
||||
<section className="csq-ai-section csq-ai-product">
|
||||
<Image className="csq-ai-product__image" width={104} height={104} src={product.hdThumbUrl} fallback="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" />
|
||||
<div className="csq-ai-product__content">
|
||||
<Title level={5}>{product.goodsName || goods?.goodsName || '未获取到商品名称'}</Title>
|
||||
<div className="csq-ai-product__meta">
|
||||
<span>商品ID:{goods?.goodsId || '--'}</span>
|
||||
<span>所属店铺:{product.shopName || goods?.shopName || '--'}</span>
|
||||
{(product.cate1Name || product.cate2Name || product.cate3Name) && (
|
||||
<span>类目:{[product.cate1Name, product.cate2Name, product.cate3Name].filter(Boolean).join(' / ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="csq-ai-section">
|
||||
<div className="csq-ai-section__title">
|
||||
<div><Title level={5}>质检范围</Title><Text type="secondary">一次性分析,最长支持30天</Text></div>
|
||||
<RangePicker
|
||||
value={range}
|
||||
allowClear={false}
|
||||
disabled={analysisLoading}
|
||||
onChange={(value) => {
|
||||
setRange(normalizeRange(value));
|
||||
setAnalysisJob(null);
|
||||
setAnalysisResult(null);
|
||||
setAnalysisError('');
|
||||
}}
|
||||
disabledDate={(current, info) => {
|
||||
if (current?.isAfter(dayjs(), 'day')) return true;
|
||||
return info?.from ? Math.abs(current.startOf('day').diff(info.from.startOf('day'), 'day')) > 29 : false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="csq-ai-metrics">
|
||||
{metricItems.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}
|
||||
</div>
|
||||
<div className="csq-ai-period-note">
|
||||
<Tag>{params.startDate} 至 {params.endDate}</Tag>
|
||||
<Text type="secondary">商品表现按所选周期汇总,比例指标按日均值展示。</Text>
|
||||
</div>
|
||||
</section>
|
||||
</Spin>
|
||||
|
||||
<section className="csq-ai-section csq-ai-analysis-section">
|
||||
<div className="csq-ai-section__title"><Title level={5}>诊断结果</Title></div>
|
||||
{analysisLoading && (
|
||||
<div className="csq-ai-progress" role="status" aria-live="polite">
|
||||
<div className="csq-ai-progress__heading">
|
||||
<div>
|
||||
<strong>{progress.message || '正在准备分析聊天记录'}</strong>
|
||||
<Text type="secondary">{batchProgress}</Text>
|
||||
</div>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<Progress percent={progressPercent} showInfo={false} status="active" />
|
||||
<div className="csq-ai-progress__meta">
|
||||
<Text type="secondary">已用时 {formatElapsed(elapsedSeconds)}</Text>
|
||||
<Text type={pollWarning ? 'warning' : 'secondary'}>{progressHint}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{analysisError && (
|
||||
<Alert
|
||||
className="csq-ai-progress-error"
|
||||
type="error"
|
||||
showIcon
|
||||
message="质检未完成"
|
||||
description={analysisError}
|
||||
/>
|
||||
)}
|
||||
{!analysisLoading && (
|
||||
<AiQualityResult
|
||||
analysis={analysisResult?.analysis || null}
|
||||
chatContext={{
|
||||
goodsId: goods?.goodsId,
|
||||
shopName: product.shopName || goods?.shopName,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiQualityDrawer;
|
||||
@ -0,0 +1,160 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Drawer, Empty, Image, Input, Pagination, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
||||
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { getCsQualityAnalysisHistory, getCsQualityAnalysisHistoryDetail } from '../../api/csQuality';
|
||||
import AiQualityResult from './AiQualityResult';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const STATUS_MAP = {
|
||||
succeeded: { color: 'success', text: '已完成' },
|
||||
queued: { color: 'default', text: '排队中' },
|
||||
running: { color: 'processing', text: '分析中' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
};
|
||||
|
||||
const AiQualityHistoryDrawer = ({ open, onClose, refreshToken = 0 }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [goodsId, setGoodsId] = useState('');
|
||||
const [shopName, setShopName] = useState('');
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detail, setDetail] = useState(null);
|
||||
|
||||
const loadHistory = useCallback(async (nextPage = page) => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getCsQualityAnalysisHistory({ page: nextPage, pageSize: 20, goodsId: goodsId.trim(), shopName: shopName.trim() });
|
||||
const payload = response?.data?.data || response?.data || response;
|
||||
setRows(Array.isArray(payload.rows) ? payload.rows : []);
|
||||
setTotal(Number(payload.total || 0));
|
||||
setPage(Number(payload.page || nextPage));
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || error.message || '加载历史记录失败');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open, page, goodsId, shopName]);
|
||||
|
||||
useEffect(() => { if (open) loadHistory(1); }, [open, refreshToken]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const showDetail = async (record) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const response = await getCsQualityAnalysisHistoryDetail(record.id);
|
||||
setDetail(response?.data?.data || response?.data || response);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || error.message || '加载历史详情失败');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '商品',
|
||||
key: 'goods',
|
||||
width: 320,
|
||||
render: (_, record) => (
|
||||
<div className="csq-history-product">
|
||||
<Image width={44} height={44} src={record.productImageUrl} preview={false} />
|
||||
<div><strong>{record.goodsName || record.goodsId}</strong><span>ID:{record.goodsId}</span><span>{record.shopName || '--'}</span></div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '聊天周期', key: 'period', width: 190,
|
||||
render: (_, record) => <div className="csq-history-stack"><span>{dayjs(record.chatStartDate).format('YYYY-MM-DD')} 至 {dayjs(record.chatEndDate).format('YYYY-MM-DD')}</span><span>{record.customerCount || 0} 客户 · {record.messageCount || 0} 消息</span></div>,
|
||||
},
|
||||
{ title: '判断', dataIndex: 'conversationType', width: 90, render: (value) => value ? <Tag>{value}</Tag> : '--' },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
render: (value, record) => {
|
||||
const item = STATUS_MAP[value] || { color: 'default', text: value || '--' };
|
||||
const active = value === 'queued' || value === 'running';
|
||||
return (
|
||||
<div className="csq-history-status">
|
||||
<Tag color={item.color}>{item.text}</Tag>
|
||||
{active && <span>{Math.min(100, Math.max(0, Number(record.progressPercent || 0)))}%</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作信息', key: 'operator', width: 180,
|
||||
render: (_, record) => <div className="csq-history-stack"><span>{record.createdByName || '--'}</span><span>{record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : '--'}</span></div>,
|
||||
},
|
||||
{ title: '', key: 'action', width: 52, fixed: 'right', render: (_, record) => <Button type="text" icon={<EyeOutlined />} title="查看质检结果" onClick={() => showDetail(record)} /> },
|
||||
], []);
|
||||
|
||||
const detailProduct = detail ? {
|
||||
goodsId: detail.goods_id,
|
||||
goodsName: detail.goods_name,
|
||||
shopName: detail.shop_name,
|
||||
imageUrl: detail.product_image_url,
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer title="AI质检历史记录" width={1120} open={open} onClose={onClose} destroyOnHidden extra={<Button icon={<ReloadOutlined />} loading={loading} onClick={() => loadHistory(page)}>刷新</Button>}>
|
||||
<div className="csq-history-filter">
|
||||
<Space wrap>
|
||||
<Input value={goodsId} onChange={(event) => setGoodsId(event.target.value)} placeholder="商品ID" allowClear style={{ width: 180 }} onPressEnter={() => loadHistory(1)} />
|
||||
<Input value={shopName} onChange={(event) => setShopName(event.target.value)} placeholder="店铺名称" allowClear style={{ width: 220 }} onPressEnter={() => loadHistory(1)} />
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={() => loadHistory(1)}>查询</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
scroll={{ x: 980 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无AI质检历史记录" /> }}
|
||||
/>
|
||||
{total > 20 && <div className="csq-history-pagination"><Pagination current={page} total={total} pageSize={20} showSizeChanger={false} onChange={(value) => loadHistory(value)} /></div>}
|
||||
</Drawer>
|
||||
|
||||
<Drawer title="历史质检详情" width={920} open={detailOpen} onClose={() => setDetailOpen(false)} destroyOnHidden>
|
||||
<Spin spinning={detailLoading}>
|
||||
{detail ? (
|
||||
<>
|
||||
<section className="csq-ai-section csq-ai-product csq-history-detail-product">
|
||||
<Image width={88} height={88} src={detailProduct.imageUrl} />
|
||||
<div className="csq-ai-product__content">
|
||||
<Title level={5}>{detailProduct.goodsName || detailProduct.goodsId}</Title>
|
||||
<div className="csq-ai-product__meta">
|
||||
<span>商品ID:{detailProduct.goodsId}</span><span>所属店铺:{detailProduct.shopName || '--'}</span>
|
||||
<span>聊天周期:{dayjs(detail.chat_start_date).format('YYYY-MM-DD')} 至 {dayjs(detail.chat_end_date).format('YYYY-MM-DD')}</span>
|
||||
<span>分析人:{detail.created_by_name || '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{detail.error_message && <div className="csq-error">{detail.error_message}</div>}
|
||||
<AiQualityResult
|
||||
analysis={detail.analysis || detail.aiResponse}
|
||||
chatContext={{
|
||||
goodsId: detailProduct.goodsId,
|
||||
shopName: detailProduct.shopName,
|
||||
startDate: dayjs(detail.chat_start_date).format('YYYY-MM-DD'),
|
||||
endDate: dayjs(detail.chat_end_date).format('YYYY-MM-DD'),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未读取到历史详情" />}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiQualityHistoryDrawer;
|
||||
516
frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx
Normal file
516
frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx
Normal file
@ -0,0 +1,516 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Button, Descriptions, Empty, Image, Modal, Spin, Table, Tabs, Tag, Typography } from 'antd';
|
||||
import { EyeOutlined } from '@ant-design/icons';
|
||||
import { getCsQualityMessages } from '../../api/csQuality';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
const TYPE_COLOR = {
|
||||
售前: 'blue',
|
||||
售后: 'orange',
|
||||
混合: 'purple',
|
||||
无有效咨询: 'default',
|
||||
};
|
||||
const IMAGE_URL_PATTERN = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i;
|
||||
const SUMMARY_BUTTON_STYLE = {
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
height: 'auto',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 4,
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
function formatNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number.toLocaleString('zh-CN') : '0';
|
||||
}
|
||||
|
||||
function formatConfidence(value) {
|
||||
return `${Math.round((Number(value) || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
function getIssueTitle(record) {
|
||||
return [record?.primary_reason, record?.secondary_reason].filter(Boolean).join(' / ') || '问题明细';
|
||||
}
|
||||
|
||||
function getAffectedConversationIds(record) {
|
||||
const sourceRows = Array.isArray(record?.sourceRows) ? record.sourceRows : [];
|
||||
const records = [record, ...sourceRows].filter(Boolean);
|
||||
return Array.from(new Set(records.flatMap((item) => [
|
||||
...(Array.isArray(item.affected_conversation_ids) ? item.affected_conversation_ids : []),
|
||||
...(Array.isArray(item.affectedConversationIds) ? item.affectedConversationIds : []),
|
||||
...(Array.isArray(item.evidence) ? item.evidence.map((entry) => entry?.conversation_id || entry?.conversationId) : []),
|
||||
]).map((value) => String(value || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function groupChatMessages(rows, conversationIds) {
|
||||
const allowedIds = new Set(conversationIds);
|
||||
const groups = new Map();
|
||||
rows.forEach((row, index) => {
|
||||
const uid = String(row?.uid || '').trim();
|
||||
if (!uid || !allowedIds.has(uid)) return;
|
||||
if (!groups.has(uid)) {
|
||||
groups.set(uid, {
|
||||
uid,
|
||||
nickname: row.nickname || uid,
|
||||
csname: row.csname || '',
|
||||
rows: [],
|
||||
firstIndex: index,
|
||||
});
|
||||
}
|
||||
const group = groups.get(uid);
|
||||
group.nickname = group.nickname || row.nickname || uid;
|
||||
group.csname = group.csname || row.csname || '';
|
||||
group.rows.push(row);
|
||||
});
|
||||
return Array.from(groups.values()).sort((left, right) => left.firstIndex - right.firstIndex);
|
||||
}
|
||||
|
||||
function withOccurrenceShare(sourceRows) {
|
||||
const total = sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0);
|
||||
return sourceRows.map((row) => ({
|
||||
...row,
|
||||
occurrence_share: total > 0 ? (Number(row.occurrence_count) || 0) / total : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregateIssueTags(sourceRows) {
|
||||
const groups = new Map();
|
||||
sourceRows.forEach((row) => {
|
||||
const key = row.primary_reason || '其他问题';
|
||||
const group = groups.get(key) || { primary_reason: key, sourceRows: [] };
|
||||
group.sourceRows.push(row);
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
const aggregated = Array.from(groups.values()).map((group) => {
|
||||
const occurrenceCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0);
|
||||
const affectedCustomerCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.affected_customer_count) || 0), 0);
|
||||
const consultationTypes = Array.from(new Set(group.sourceRows.map((row) => row.consultation_type).filter(Boolean)));
|
||||
const confidenceWeight = group.sourceRows.reduce((sum, row) => sum + ((Number(row.confidence) || 0) * (Number(row.occurrence_count) || 1)), 0);
|
||||
const confidenceBase = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 1), 0);
|
||||
const evidence = group.sourceRows.flatMap((row) => Array.isArray(row.evidence) ? row.evidence : []).filter((item, index, list) => (
|
||||
list.findIndex((candidate) => `${candidate.conversation_id}|${candidate.message_time}|${candidate.text}` === `${item.conversation_id}|${item.message_time}|${item.text}`) === index
|
||||
));
|
||||
const affectedConversationIds = getAffectedConversationIds({ sourceRows: group.sourceRows });
|
||||
return {
|
||||
...group,
|
||||
consultation_type: consultationTypes.length === 1 ? consultationTypes[0] : '混合',
|
||||
secondary_reason: `${group.sourceRows.length} 个细分问题`,
|
||||
occurrence_count: occurrenceCount,
|
||||
affected_customer_count: affectedCustomerCount,
|
||||
affected_conversation_ids: affectedConversationIds,
|
||||
confidence: confidenceBase > 0 ? confidenceWeight / confidenceBase : 0,
|
||||
evidence,
|
||||
product_improvement_areas: Array.from(new Set(group.sourceRows.flatMap((row) => row.product_improvement_areas || []))),
|
||||
image_urls: Array.from(new Set(group.sourceRows.flatMap((row) => row.image_urls || []))),
|
||||
improvement_suggestion: Array.from(new Set(group.sourceRows.map((row) => row.improvement_suggestion).filter(Boolean))).join(';'),
|
||||
};
|
||||
});
|
||||
return withOccurrenceShare(aggregated).sort((left, right) => right.occurrence_count - left.occurrence_count);
|
||||
}
|
||||
|
||||
const AiQualityResult = ({ analysis, chatContext = {} }) => {
|
||||
const [categoryDrawer, setCategoryDrawer] = useState(null);
|
||||
const [evidenceRecord, setEvidenceRecord] = useState(null);
|
||||
const [detailTab, setDetailTab] = useState('all-chats');
|
||||
const [relatedChats, setRelatedChats] = useState({ loading: false, rows: [], error: '' });
|
||||
const [expandedCustomerIds, setExpandedCustomerIds] = useState([]);
|
||||
const chatRequestRef = useRef(0);
|
||||
const rows = Array.isArray(analysis?.results) ? analysis.results : [];
|
||||
const summary = analysis?.summary || {};
|
||||
const activeCategoryDrawer = categoryDrawer?.analysis === analysis ? categoryDrawer : null;
|
||||
const activeEvidenceRecord = evidenceRecord?.analysis === analysis ? evidenceRecord.record : null;
|
||||
const relatedConversationIds = useMemo(
|
||||
() => getAffectedConversationIds(activeEvidenceRecord),
|
||||
[activeEvidenceRecord]
|
||||
);
|
||||
const groupedRelatedChats = useMemo(
|
||||
() => groupChatMessages(relatedChats.rows, relatedConversationIds),
|
||||
[relatedChats.rows, relatedConversationIds]
|
||||
);
|
||||
|
||||
const openEvidenceDetail = (record) => {
|
||||
const requestId = chatRequestRef.current + 1;
|
||||
chatRequestRef.current = requestId;
|
||||
setEvidenceRecord({ analysis, record });
|
||||
setDetailTab('all-chats');
|
||||
setExpandedCustomerIds([]);
|
||||
const conversationIds = getAffectedConversationIds(record);
|
||||
const goodsId = String(chatContext.goodsId || '').trim();
|
||||
const startDate = String(chatContext.startDate || '').trim();
|
||||
const endDate = String(chatContext.endDate || '').trim();
|
||||
if (!goodsId || !startDate || !endDate || conversationIds.length === 0) {
|
||||
setRelatedChats({
|
||||
loading: false,
|
||||
rows: [],
|
||||
error: conversationIds.length === 0
|
||||
? '当前质检记录没有保存涉及客户信息,暂无可关联的完整聊天记录。'
|
||||
: '当前质检记录缺少商品或聊天周期,无法读取完整聊天记录。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRelatedChats({ loading: true, rows: [], error: '' });
|
||||
getCsQualityMessages({
|
||||
goodsId,
|
||||
startDate,
|
||||
endDate,
|
||||
shopName: chatContext.shopName || '',
|
||||
}).then((response) => {
|
||||
if (chatRequestRef.current !== requestId) return;
|
||||
const payload = response?.data?.data || response?.data || response;
|
||||
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
const allowedConversationIds = new Set(conversationIds);
|
||||
const firstAffectedRow = nextRows.find((row) => allowedConversationIds.has(String(row?.uid || '').trim()));
|
||||
setRelatedChats({
|
||||
loading: false,
|
||||
rows: nextRows,
|
||||
error: '',
|
||||
});
|
||||
setExpandedCustomerIds(firstAffectedRow ? [String(firstAffectedRow.uid).trim()] : []);
|
||||
}).catch((error) => {
|
||||
if (chatRequestRef.current !== requestId) return;
|
||||
setRelatedChats({
|
||||
loading: false,
|
||||
rows: [],
|
||||
error: error.response?.data?.error || error.message || '完整聊天记录加载失败',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const closeEvidenceDetail = () => {
|
||||
chatRequestRef.current += 1;
|
||||
setEvidenceRecord(null);
|
||||
setRelatedChats({ loading: false, rows: [], error: '' });
|
||||
setExpandedCustomerIds([]);
|
||||
};
|
||||
|
||||
const openCategoryDrawer = (title, filterType = '', aggregate = false) => {
|
||||
const filteredRows = filterType ? rows.filter((row) => row.consultation_type === filterType) : rows;
|
||||
setCategoryDrawer({
|
||||
title,
|
||||
filterType,
|
||||
aggregate,
|
||||
analysis,
|
||||
rows: aggregate ? aggregateIssueTags(filteredRows) : withOccurrenceShare(filteredRows),
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'consultation_type',
|
||||
width: 72,
|
||||
render: (value) => <Tag color={TYPE_COLOR[value] || 'default'}>{value || '--'}</Tag>,
|
||||
},
|
||||
{ title: '一级原因', dataIndex: 'primary_reason', width: 130, ellipsis: true },
|
||||
{ title: '二级原因', dataIndex: 'secondary_reason', width: 150, ellipsis: true },
|
||||
{
|
||||
title: '问题次数',
|
||||
dataIndex: 'occurrence_count',
|
||||
width: 88,
|
||||
align: 'right',
|
||||
render: formatNumber,
|
||||
},
|
||||
{
|
||||
title: '涉及客户',
|
||||
dataIndex: 'affected_customer_count',
|
||||
width: 88,
|
||||
align: 'right',
|
||||
render: formatNumber,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 86,
|
||||
align: 'right',
|
||||
render: formatConfidence,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 98,
|
||||
fixed: 'right',
|
||||
render: (_value, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => openEvidenceDetail(record)}
|
||||
>
|
||||
查看明细
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const categoryColumns = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'consultation_type',
|
||||
width: 72,
|
||||
render: (value) => <Tag color={TYPE_COLOR[value] || 'default'}>{value || '--'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '问题标签',
|
||||
key: 'reason',
|
||||
render: (_value, record) => <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}><strong>{record.primary_reason || '--'}</strong><Text type="secondary">{record.secondary_reason || '--'}</Text></div>,
|
||||
},
|
||||
{ title: '问题次数', dataIndex: 'occurrence_count', width: 96, align: 'right', render: formatNumber },
|
||||
{ title: '问题占比', dataIndex: 'occurrence_share', width: 96, align: 'right', render: (value) => formatConfidence(value) },
|
||||
{ title: '涉及客户', dataIndex: 'affected_customer_count', width: 96, align: 'right', render: formatNumber },
|
||||
{ title: '置信度', dataIndex: 'confidence', width: 86, align: 'right', render: formatConfidence },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 98,
|
||||
fixed: 'right',
|
||||
render: (_value, record) => <Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openEvidenceDetail(record)}>聊天明细</Button>,
|
||||
},
|
||||
];
|
||||
|
||||
if (!analysis) {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择日期后开始AI质检" />;
|
||||
}
|
||||
|
||||
const renderAllChats = (record) => {
|
||||
const messageCount = groupedRelatedChats.reduce((sum, group) => sum + group.rows.length, 0);
|
||||
const expectedCustomerCount = Number(record.affected_customer_count) || 0;
|
||||
const hasIncompleteCustomerIds = expectedCustomerCount > relatedConversationIds.length;
|
||||
|
||||
return (
|
||||
<Spin spinning={relatedChats.loading}>
|
||||
<div className="csq-ai-chat-summary">
|
||||
<Text type="secondary">
|
||||
已关联 {formatNumber(groupedRelatedChats.length)} 个客户、{formatNumber(messageCount)} 条完整聊天记录
|
||||
</Text>
|
||||
</div>
|
||||
{hasIncompleteCustomerIds && !relatedChats.loading && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="这条历史质检记录只保存了部分涉及客户"
|
||||
description={`质检结果记录了 ${formatNumber(expectedCustomerCount)} 个涉及客户,当前可识别 ${formatNumber(relatedConversationIds.length)} 个。重新质检后会保存全部涉及客户。`}
|
||||
/>
|
||||
)}
|
||||
{relatedChats.error && <Alert type="warning" showIcon message={relatedChats.error} />}
|
||||
{!relatedChats.loading && !relatedChats.error && groupedRelatedChats.length === 0 && (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未读取到涉及客户的聊天记录" />
|
||||
)}
|
||||
<div className="csq-ai-chat-collapse">
|
||||
{groupedRelatedChats.map((group) => (
|
||||
<details
|
||||
className="csq-ai-chat-customer"
|
||||
key={group.uid}
|
||||
open={expandedCustomerIds.includes(group.uid)}
|
||||
onToggle={(event) => {
|
||||
const uid = group.uid;
|
||||
setExpandedCustomerIds((current) => event.currentTarget.open
|
||||
? Array.from(new Set([...current, uid]))
|
||||
: current.filter((value) => value !== uid));
|
||||
}}
|
||||
>
|
||||
<summary className="csq-ai-chat-customer-heading">
|
||||
<div>
|
||||
<strong>{group.nickname || group.uid}</strong>
|
||||
<Text type="secondary">UID:{group.uid}</Text>
|
||||
</div>
|
||||
<Tag>{formatNumber(group.rows.length)} 条消息</Tag>
|
||||
</summary>
|
||||
<div className="csq-ai-chat-customer-body">
|
||||
<div className="csq-ai-chat-messages">
|
||||
{group.rows.map((item, index) => {
|
||||
const isService = item.directionLabel === '客服';
|
||||
return (
|
||||
<div
|
||||
className={`csq-ai-chat-message csq-ai-chat-message--${isService ? 'service' : 'buyer'}`}
|
||||
key={`${item.msgId || item.msgTime || index}-${index}`}
|
||||
>
|
||||
<div className="csq-ai-chat-message__meta">
|
||||
<Tag color={isService ? 'green' : 'blue'}>{item.directionLabel || '未标记'}</Tag>
|
||||
{item.csname ? <span>客服:{item.csname}</span> : null}
|
||||
<span>{item.msgTime || '--'}</span>
|
||||
</div>
|
||||
<Paragraph>{item.content || '--'}</Paragraph>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
const renderKeyEvidence = (record) => (
|
||||
<>
|
||||
{Array.isArray(record.evidence) && record.evidence.length > 0 ? (
|
||||
<div className="csq-ai-evidence-list">
|
||||
{record.evidence.map((item, index) => (
|
||||
<blockquote key={`${item.text}-${index}`}>
|
||||
<span>{item.nickname || item.conversation_id || '买家'}{item.message_time ? ` · ${item.message_time}` : ''}</span>
|
||||
<Paragraph>{item.text || '--'}</Paragraph>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可展示的关键证据" />}
|
||||
{Array.isArray(record.image_urls) && record.image_urls.some((url) => IMAGE_URL_PATTERN.test(String(url))) && (
|
||||
<div className="csq-ai-result__images">
|
||||
{record.image_urls.filter((url) => IMAGE_URL_PATTERN.test(String(url))).map((url) => <Image key={url} width={72} height={72} src={url} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderRecommendation = (record) => (
|
||||
<div className="csq-ai-result__recommendation">
|
||||
{Array.isArray(record.product_improvement_areas) && record.product_improvement_areas.length > 0 && (
|
||||
<div className="csq-ai-result__tags">
|
||||
<Text strong>影响环节:</Text>
|
||||
{record.product_improvement_areas.map((item) => <Tag key={item}>{item}</Tag>)}
|
||||
</div>
|
||||
)}
|
||||
{record.improvement_suggestion && <div><Text strong>改进建议:</Text>{record.improvement_suggestion}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailPane = (content, record) => (
|
||||
<div className="csq-ai-detail-pane">
|
||||
{content}
|
||||
{renderRecommendation(record)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEvidence = (record) => (
|
||||
<div className="csq-ai-result__detail">
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
<Descriptions.Item label="咨询类型"><Tag color={TYPE_COLOR[record.consultation_type] || 'default'}>{record.consultation_type || '--'}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label="置信度">{formatConfidence(record.confidence)}</Descriptions.Item>
|
||||
<Descriptions.Item label="问题次数">{formatNumber(record.occurrence_count)}</Descriptions.Item>
|
||||
<Descriptions.Item label="涉及客户">{formatNumber(record.affected_customer_count)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{record.buyer_intent && <div><Text strong>买家诉求:</Text>{record.buyer_intent}</div>}
|
||||
{Array.isArray(record.sourceRows) && record.sourceRows.length > 0 && (
|
||||
<div className="csq-ai-result__tags">
|
||||
<Text strong>细分问题:</Text>
|
||||
{record.sourceRows.map((item, index) => <Tag key={`${item.secondary_reason}-${index}`}>{item.secondary_reason || '--'} · {formatNumber(item.occurrence_count)} 次</Tag>)}
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
activeKey={detailTab}
|
||||
onChange={setDetailTab}
|
||||
items={[
|
||||
{
|
||||
key: 'all-chats',
|
||||
label: `全部聊天记录 (${formatNumber(relatedConversationIds.length)})`,
|
||||
children: renderDetailPane(renderAllChats(record), record),
|
||||
},
|
||||
{
|
||||
key: 'key-evidence',
|
||||
label: `关键证据 (${formatNumber(record.evidence?.length || 0)})`,
|
||||
children: renderDetailPane(renderKeyEvidence(record), record),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="csq-ai-result">
|
||||
<div className="csq-ai-result__summary">
|
||||
<div>
|
||||
<Text type="secondary">综合判断</Text>
|
||||
<Tag color={TYPE_COLOR[analysis.conversation_type] || 'default'}>{analysis.conversation_type || '--'}</Tag>
|
||||
</div>
|
||||
<div><Text type="secondary">会话数</Text><strong>{formatNumber(summary.conversation_count)}</strong></div>
|
||||
<Button type="text" style={SUMMARY_BUTTON_STYLE} onClick={() => openCategoryDrawer('售前会话明细', '售前')}>
|
||||
<Text type="secondary">售前会话</Text><strong>{formatNumber(summary.pre_sale_conversation_count)}</strong>
|
||||
</Button>
|
||||
<Button type="text" style={SUMMARY_BUTTON_STYLE} onClick={() => openCategoryDrawer('售后会话明细', '售后')}>
|
||||
<Text type="secondary">售后会话</Text><strong>{formatNumber(summary.after_sale_conversation_count)}</strong>
|
||||
</Button>
|
||||
<Button type="text" style={SUMMARY_BUTTON_STYLE} onClick={() => openCategoryDrawer('问题标签明细', '', true)}>
|
||||
<Text type="secondary">问题标签</Text><strong>{formatNumber(rows.length)}</strong>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Array.isArray(summary.main_findings) && summary.main_findings.length > 0 && (
|
||||
<div className="csq-ai-findings">
|
||||
<Text strong>主要发现</Text>
|
||||
<ul>{summary.main_findings.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
className="csq-ai-result__table"
|
||||
size="small"
|
||||
rowKey={(_record, index) => `quality-result-${index}`}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
scroll={{ x: 820 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未识别到有效咨询问题" /> }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={activeCategoryDrawer?.title || '问题标签明细'}
|
||||
width={860}
|
||||
open={Boolean(activeCategoryDrawer)}
|
||||
onCancel={() => setCategoryDrawer(null)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 16, marginBottom: 16, paddingBottom: 12, borderBottom: '1px solid var(--sycm-border-light)', flexWrap: 'wrap' }}>
|
||||
<Title level={5} style={{ margin: 0 }}>{activeCategoryDrawer?.filterType ? `${activeCategoryDrawer.filterType}问题分类` : '问题分类汇总'}</Title>
|
||||
<Text type="secondary">
|
||||
{activeCategoryDrawer?.aggregate
|
||||
? `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 类问题,包含 ${formatNumber(rows.length)} 个细分标签`
|
||||
: `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 个问题标签`}
|
||||
</Text>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_record, index) => `quality-category-${index}`}
|
||||
columns={categoryColumns}
|
||||
dataSource={activeCategoryDrawer?.rows || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 760 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该类会话暂无问题标签" /> }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
className="csq-ai-detail-modal"
|
||||
title={activeEvidenceRecord ? `${getIssueTitle(activeEvidenceRecord)}明细` : '问题明细'}
|
||||
width={960}
|
||||
open={Boolean(activeEvidenceRecord)}
|
||||
onCancel={closeEvidenceDetail}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: {
|
||||
height: 'min(720px, calc(100vh - 180px))',
|
||||
overflow: 'hidden',
|
||||
paddingRight: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{activeEvidenceRecord ? renderEvidence(activeEvidenceRecord) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiQualityResult;
|
||||
634
frontend/src/pages/CustomerServiceQuality/index.jsx
Normal file
634
frontend/src/pages/CustomerServiceQuality/index.jsx
Normal file
@ -0,0 +1,634 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
Pagination,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
HistoryOutlined,
|
||||
MessageOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { SycmBusinessPanel, SycmPage } from '../../components/Common';
|
||||
import {
|
||||
getCsQualityDataMeta,
|
||||
getCsQualityGoods,
|
||||
getCsQualityCustomers,
|
||||
getCsQualityMessages,
|
||||
} from '../../api/csQuality';
|
||||
import AiQualityDrawer from './AiQualityDrawer';
|
||||
import AiQualityHistoryDrawer from './AiQualityHistoryDrawer';
|
||||
import './style.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const DEFAULT_DATE = dayjs().subtract(1, 'day');
|
||||
const DEFAULT_RANGE = [DEFAULT_DATE, DEFAULT_DATE];
|
||||
const PAGE_SIZE = 20;
|
||||
const CHAT_IMAGE_URL_RE = /^https?:\/\/\S+$/i;
|
||||
const CHAT_IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i;
|
||||
const CHAT_VIDEO_EXT_RE = /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i;
|
||||
|
||||
function formatNumber(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '0';
|
||||
return num.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatText(value) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildSingleDayRange(value) {
|
||||
const date = dayjs(value);
|
||||
return date.isValid() ? [date, date] : DEFAULT_RANGE;
|
||||
}
|
||||
|
||||
function isServiceDirection(value) {
|
||||
return String(value || '').includes('客服');
|
||||
}
|
||||
|
||||
function getDirectionTone(value) {
|
||||
if (isServiceDirection(value)) return 'outbound';
|
||||
return 'inbound';
|
||||
}
|
||||
|
||||
function getChatImageUrl(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || !CHAT_IMAGE_URL_RE.test(text)) return '';
|
||||
try {
|
||||
const url = new window.URL(text);
|
||||
if (CHAT_VIDEO_EXT_RE.test(url.pathname || '')) return '';
|
||||
if (CHAT_IMAGE_EXT_RE.test(url.pathname || '')) {
|
||||
return text;
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
return '';
|
||||
}
|
||||
|
||||
function sortChatRows(rows = []) {
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftTime = dayjs(left?.msgTime).valueOf();
|
||||
const rightTime = dayjs(right?.msgTime).valueOf();
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return String(left?.msgTime || '').localeCompare(String(right?.msgTime || ''), 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
function downloadTextFile(filename, content) {
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const CustomerServiceQuality = () => {
|
||||
const [goodsLoading, setGoodsLoading] = useState(false);
|
||||
const [customersLoading, setCustomersLoading] = useState(false);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [exportingGoodsId, setExportingGoodsId] = useState('');
|
||||
const [goodsRows, setGoodsRows] = useState([]);
|
||||
const [customerRows, setCustomerRows] = useState([]);
|
||||
const [messageRows, setMessageRows] = useState([]);
|
||||
const [selectedGoods, setSelectedGoods] = useState(null);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState(null);
|
||||
const [tableName, setTableName] = useState('');
|
||||
const [dataMeta, setDataMeta] = useState({ minDate: '', latestDate: '', messageCount: 0 });
|
||||
const [errorText, setErrorText] = useState('');
|
||||
const [goodsPage, setGoodsPage] = useState(1);
|
||||
const [customersPage, setCustomersPage] = useState(1);
|
||||
const [messagesPage, setMessagesPage] = useState(1);
|
||||
|
||||
const [dateRange, setDateRange] = useState(DEFAULT_RANGE);
|
||||
const [shopName, setShopName] = useState('');
|
||||
const [goodsId, setGoodsId] = useState('');
|
||||
const [aiGoods, setAiGoods] = useState(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyRefreshToken, setHistoryRefreshToken] = useState(0);
|
||||
const goodsRequestRef = useRef(0);
|
||||
const customersRequestRef = useRef(0);
|
||||
const messagesRequestRef = useRef(0);
|
||||
|
||||
const buildBaseParams = useCallback((filters) => {
|
||||
const nextFilters = filters || { dateRange, shopName, goodsId };
|
||||
const params = {};
|
||||
if (nextFilters.dateRange?.length === 2) {
|
||||
params.startDate = nextFilters.dateRange[0].format('YYYY-MM-DD');
|
||||
params.endDate = nextFilters.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
if (nextFilters.shopName) params.shopName = String(nextFilters.shopName).trim();
|
||||
if (nextFilters.goodsId) params.goodsId = String(nextFilters.goodsId).trim();
|
||||
return params;
|
||||
}, [dateRange, shopName, goodsId]);
|
||||
|
||||
const loadMessages = useCallback(async (customerRecord, goodsRecord = selectedGoods, filters) => {
|
||||
if (!goodsRecord?.goodsId || !customerRecord?.uid) return;
|
||||
const requestId = messagesRequestRef.current + 1;
|
||||
messagesRequestRef.current = requestId;
|
||||
setSelectedCustomer(customerRecord);
|
||||
setMessageRows([]);
|
||||
setMessagesPage(1);
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const response = await getCsQualityMessages({
|
||||
...buildBaseParams(filters),
|
||||
goodsId: goodsRecord.goodsId,
|
||||
uid: customerRecord.uid,
|
||||
});
|
||||
if (requestId !== messagesRequestRef.current) return;
|
||||
const data = response?.data || response;
|
||||
const payload = data?.data || data;
|
||||
setMessageRows(Array.isArray(payload.rows) ? payload.rows : []);
|
||||
setSelectedCustomer(customerRecord);
|
||||
setMessagesPage(1);
|
||||
setErrorText('');
|
||||
} catch (error) {
|
||||
if (requestId !== messagesRequestRef.current) return;
|
||||
const msg = error.response?.data?.error || error.message || '加载聊天内容失败';
|
||||
message.error(msg);
|
||||
setErrorText(msg);
|
||||
setMessageRows([]);
|
||||
} finally {
|
||||
if (requestId === messagesRequestRef.current) setMessagesLoading(false);
|
||||
}
|
||||
}, [selectedGoods, buildBaseParams]);
|
||||
|
||||
const loadCustomers = useCallback(async (goodsRecord, filters) => {
|
||||
if (!goodsRecord?.goodsId) return;
|
||||
const requestId = customersRequestRef.current + 1;
|
||||
customersRequestRef.current = requestId;
|
||||
messagesRequestRef.current += 1;
|
||||
setCustomersLoading(true);
|
||||
setMessagesLoading(false);
|
||||
setSelectedGoods(goodsRecord);
|
||||
setCustomersPage(1);
|
||||
setMessagesPage(1);
|
||||
try {
|
||||
const baseParams = buildBaseParams(filters);
|
||||
const response = await getCsQualityCustomers({
|
||||
...baseParams,
|
||||
goodsId: goodsRecord.goodsId,
|
||||
snapshotScopeGoodsId: baseParams.goodsId || undefined,
|
||||
});
|
||||
if (requestId !== customersRequestRef.current) return;
|
||||
const data = response?.data || response;
|
||||
const payload = data?.data || data;
|
||||
const rows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
setCustomerRows(rows);
|
||||
setErrorText('');
|
||||
if (rows[0]) {
|
||||
await loadMessages(rows[0], goodsRecord, filters);
|
||||
} else {
|
||||
setMessageRows([]);
|
||||
setSelectedCustomer(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== customersRequestRef.current) return;
|
||||
const msg = error.response?.data?.error || error.message || '加载客户列表失败';
|
||||
message.error(msg);
|
||||
setErrorText(msg);
|
||||
setCustomerRows([]);
|
||||
setMessageRows([]);
|
||||
setSelectedCustomer(null);
|
||||
} finally {
|
||||
if (requestId === customersRequestRef.current) setCustomersLoading(false);
|
||||
}
|
||||
}, [buildBaseParams, loadMessages]);
|
||||
|
||||
const loadGoods = useCallback(async (filters) => {
|
||||
const requestId = goodsRequestRef.current + 1;
|
||||
goodsRequestRef.current = requestId;
|
||||
customersRequestRef.current += 1;
|
||||
messagesRequestRef.current += 1;
|
||||
setGoodsLoading(true);
|
||||
setCustomersLoading(false);
|
||||
setMessagesLoading(false);
|
||||
try {
|
||||
const response = await getCsQualityGoods({
|
||||
...buildBaseParams(filters),
|
||||
includeInitial: true,
|
||||
});
|
||||
if (requestId !== goodsRequestRef.current) return;
|
||||
const data = response?.data || response;
|
||||
if (!data?.success && data?.success !== undefined) throw new Error(data?.error || '加载商品聊天列表失败');
|
||||
const payload = data?.data || data;
|
||||
const rows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
setTableName(payload.tableName || '');
|
||||
setGoodsRows(rows);
|
||||
setGoodsPage(1);
|
||||
setCustomersPage(1);
|
||||
setMessagesPage(1);
|
||||
setErrorText('');
|
||||
if (rows[0]) {
|
||||
const initial = payload.initial;
|
||||
const hasInitial = String(initial?.goodsId || '') === String(rows[0].goodsId || '')
|
||||
&& Array.isArray(initial?.customers);
|
||||
if (hasInitial) {
|
||||
const initialCustomers = initial.customers;
|
||||
const initialCustomer = initialCustomers[0] || null;
|
||||
setSelectedGoods(rows[0]);
|
||||
setCustomerRows(initialCustomers);
|
||||
setSelectedCustomer(initialCustomer);
|
||||
if (initialCustomer && initial.messagesPrefetched
|
||||
&& String(initial.customerUid || '') === String(initialCustomer.uid || '')) {
|
||||
setMessageRows(Array.isArray(initial.messages) ? initial.messages : []);
|
||||
} else if (initialCustomer) {
|
||||
await loadMessages(initialCustomer, rows[0], filters);
|
||||
} else {
|
||||
setMessageRows([]);
|
||||
}
|
||||
} else {
|
||||
await loadCustomers(rows[0], filters);
|
||||
}
|
||||
} else {
|
||||
setCustomerRows([]);
|
||||
setMessageRows([]);
|
||||
setSelectedGoods(null);
|
||||
setSelectedCustomer(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== goodsRequestRef.current) return;
|
||||
const msg = error.response?.data?.error || error.message || '加载商品聊天列表失败';
|
||||
message.error(msg);
|
||||
setErrorText(msg);
|
||||
setGoodsRows([]);
|
||||
setCustomerRows([]);
|
||||
setMessageRows([]);
|
||||
setSelectedGoods(null);
|
||||
setSelectedCustomer(null);
|
||||
} finally {
|
||||
if (requestId === goodsRequestRef.current) setGoodsLoading(false);
|
||||
}
|
||||
}, [buildBaseParams, loadCustomers, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const initialize = async () => {
|
||||
let initialRange = DEFAULT_RANGE;
|
||||
try {
|
||||
const response = await getCsQualityDataMeta();
|
||||
if (!active) return;
|
||||
const data = response?.data || response;
|
||||
const payload = data?.data || data;
|
||||
setDataMeta({
|
||||
minDate: payload?.minDate || '',
|
||||
latestDate: payload?.latestDate || '',
|
||||
messageCount: Number(payload?.messageCount || 0),
|
||||
});
|
||||
if (payload?.latestDate) {
|
||||
initialRange = buildSingleDayRange(payload.latestDate);
|
||||
setDateRange(initialRange);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[CustomerServiceQuality] 加载聊天数据日期范围失败:', error.message || error);
|
||||
}
|
||||
if (active) loadGoods({ dateRange: initialRange });
|
||||
};
|
||||
initialize();
|
||||
return () => { active = false; };
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleQuery = () => loadGoods();
|
||||
|
||||
const handleReset = () => {
|
||||
const next = {
|
||||
dateRange: dataMeta.latestDate ? buildSingleDayRange(dataMeta.latestDate) : DEFAULT_RANGE,
|
||||
shopName: '',
|
||||
goodsId: '',
|
||||
};
|
||||
setDateRange(next.dateRange);
|
||||
setShopName(next.shopName);
|
||||
setGoodsId(next.goodsId);
|
||||
loadGoods(next);
|
||||
};
|
||||
|
||||
const handleOpenAiQuality = (goodsRecord, event) => {
|
||||
event?.stopPropagation?.();
|
||||
setAiGoods(goodsRecord);
|
||||
};
|
||||
|
||||
const handleDownloadGoodsChat = async (goodsRecord, event) => {
|
||||
event?.stopPropagation?.();
|
||||
if (!goodsRecord?.goodsId) return;
|
||||
setExportingGoodsId(String(goodsRecord.goodsId));
|
||||
try {
|
||||
const response = await getCsQualityMessages({
|
||||
...buildBaseParams(),
|
||||
goodsId: goodsRecord.goodsId,
|
||||
});
|
||||
const data = response?.data || response;
|
||||
const payload = data?.data || data;
|
||||
const rows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
if (!rows.length) {
|
||||
message.warning('当前筛选时间段内没有可下载的聊天记录');
|
||||
return;
|
||||
}
|
||||
const sorted = sortChatRows(rows);
|
||||
const customerMap = new Map();
|
||||
sorted.forEach((row) => {
|
||||
const key = String(row?.uid || 'unknown');
|
||||
if (!customerMap.has(key)) customerMap.set(key, { uid: row?.uid, nickname: row?.nickname, rows: [] });
|
||||
customerMap.get(key).rows.push(row);
|
||||
});
|
||||
const header = [
|
||||
'商品聊天记录导出',
|
||||
`商品ID:${goodsRecord.goodsId || '--'}`,
|
||||
`商品名称:${goodsRecord.goodsName || '--'}`,
|
||||
`店铺名称:${goodsRecord.shopName || '--'}`,
|
||||
`时间范围:${dateRange?.[0]?.format('YYYY-MM-DD') || '--'} ~ ${dateRange?.[1]?.format('YYYY-MM-DD') || '--'}`,
|
||||
`导出时间:${dayjs().format('YYYY-MM-DD HH:mm:ss')}`,
|
||||
`消息总数:${sorted.length}`,
|
||||
`客户总数:${customerMap.size}`,
|
||||
''.padEnd(64, '='),
|
||||
];
|
||||
const blocks = Array.from(customerMap.values()).map((c) => {
|
||||
const blockHeader = [`客户:${c.nickname || '--'} (UID: ${c.uid || '--'})`, `消息数:${c.rows.length}`, ''.padEnd(64, '-')];
|
||||
const lines = c.rows.map((r) => `[${r?.msgTime || '--'} | ${r?.directionLabel || ''}${r?.csname ? ` | 客服:${r.csname}` : ''}]\r\n${r?.content || '--'}`);
|
||||
return [...blockHeader, ...lines].join('\r\n');
|
||||
});
|
||||
downloadTextFile(`聊天记录_${goodsRecord.goodsId}_${dateRange?.[0]?.format('YYYYMMDD') || 'start'}_${dateRange?.[1]?.format('YYYYMMDD') || 'end'}.txt`, [...header, ...blocks].join('\r\n\r\n'));
|
||||
message.success(`已下载 ${sorted.length} 条聊天记录`);
|
||||
} catch (error) {
|
||||
message.error(error.message || '下载失败');
|
||||
} finally {
|
||||
setExportingGoodsId('');
|
||||
}
|
||||
};
|
||||
|
||||
const goodsColumns = useMemo(() => [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'goodsName',
|
||||
key: 'goodsName',
|
||||
render: (_value, record) => (
|
||||
<div className="csq-goods-cell">
|
||||
{record.hdThumbUrl ? (
|
||||
<img className="csq-goods-thumb" src={record.hdThumbUrl} alt={record.goodsName || ''} />
|
||||
) : (
|
||||
<span className="csq-goods-thumb is-empty">暂无</span>
|
||||
)}
|
||||
<div className="csq-cell-main">
|
||||
<span className="csq-cell-primary">{record.goodsName || record.goodsId || '--'}</span>
|
||||
<span className="csq-cell-secondary">ID:{record.goodsId || '--'}</span>
|
||||
{record.shopName ? <span className="csq-cell-secondary">{record.shopName}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
dataIndex: 'messageCount',
|
||||
key: 'messageCount',
|
||||
width: 150,
|
||||
render: (value, record) => (
|
||||
<div className="csq-count-cell">
|
||||
<span className="csq-cell-primary">{formatNumber(value)}</span>
|
||||
<span className="csq-cell-secondary">{formatNumber(record.userCount)} 客户</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={exportingGoodsId === String(record.goodsId)}
|
||||
className="csq-download-btn"
|
||||
onClick={(e) => handleDownloadGoodsChat(record, e)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'aiQuality',
|
||||
width: 48,
|
||||
align: 'center',
|
||||
render: (_value, record) => (
|
||||
<Tooltip title="AI质检">
|
||||
<Button
|
||||
type="text"
|
||||
className="csq-ai-action"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
onClick={(event) => handleOpenAiQuality(record, event)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
], [exportingGoodsId, dateRange, shopName, goodsId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const customerColumns = useMemo(() => [
|
||||
{
|
||||
title: '客户',
|
||||
dataIndex: 'uid',
|
||||
key: 'uid',
|
||||
render: (_value, record) => (
|
||||
<div className="csq-customer-cell">
|
||||
<div className="csq-customer-avatar"><UserOutlined /></div>
|
||||
<div className="csq-cell-main">
|
||||
<span className="csq-cell-primary">{record.nickname || record.uid || '--'}</span>
|
||||
<span className="csq-cell-secondary">{record.uid || '--'}</span>
|
||||
{record.csname ? <span className="csq-cell-secondary">客服:{record.csname}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
dataIndex: 'messageCount',
|
||||
key: 'messageCount',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
render: (value) => <span className="csq-cell-primary">{formatNumber(value)}</span>,
|
||||
},
|
||||
], []);
|
||||
|
||||
const selectedGoodsTitle = selectedGoods ? `${selectedGoods.goodsId} 的客户` : '第二级:点击商品查看客户';
|
||||
const selectedCustomerTitle = selectedCustomer ? `${selectedCustomer.nickname || selectedCustomer.uid} 的聊天` : '第三级:点击客户查看聊天';
|
||||
const pagedGoodsRows = goodsRows.slice((goodsPage - 1) * PAGE_SIZE, goodsPage * PAGE_SIZE);
|
||||
const pagedCustomerRows = customerRows.slice((customersPage - 1) * PAGE_SIZE, customersPage * PAGE_SIZE);
|
||||
const pagedMessageRows = messageRows.slice((messagesPage - 1) * PAGE_SIZE, messagesPage * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<SycmPage className="csq-page">
|
||||
<div className="csq-panel">
|
||||
<SycmBusinessPanel className="csq-panel-inner" aria-label="客服质检">
|
||||
<div className="csq-panel__heading">
|
||||
<div className="csq-panel__heading-main">
|
||||
<h2>客服质检</h2>
|
||||
<span>按商品、客服和消息方向查看聊天记录</span>
|
||||
</div>
|
||||
<Button icon={<HistoryOutlined />} onClick={() => setHistoryOpen(true)}>历史记录</Button>
|
||||
</div>
|
||||
|
||||
<div className="csq-filter">
|
||||
<Space size={12} wrap>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
allowClear={false}
|
||||
disabledDate={(current) => (
|
||||
(dataMeta.minDate && current?.isBefore(dayjs(dataMeta.minDate), 'day'))
|
||||
|| (dataMeta.latestDate && current?.isAfter(dayjs(dataMeta.latestDate), 'day'))
|
||||
)}
|
||||
/>
|
||||
<Input placeholder="店铺名称" value={shopName} onChange={(e) => setShopName(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 180 }} />
|
||||
<Input placeholder="商品ID" value={goodsId} onChange={(e) => setGoodsId(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 160 }} />
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={goodsLoading} onClick={handleQuery}>查询</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{errorText && <div className="csq-error">{errorText}</div>}
|
||||
<div className="csq-info">
|
||||
数据来源:{tableName || 'PDD 商品 T-1 聊天记录数据表'};
|
||||
数据范围:{dataMeta.minDate && dataMeta.latestDate ? `${dataMeta.minDate} 至 ${dataMeta.latestDate}` : '--'};
|
||||
累计消息 {formatNumber(dataMeta.messageCount)} 条;
|
||||
当前商品 {goodsRows.length} 个
|
||||
</div>
|
||||
|
||||
<div className="csq-drill-grid">
|
||||
<div className="csq-drill-card">
|
||||
<div className="csq-drill-card__header">
|
||||
<span>第一级:商品ID</span>
|
||||
<span className="csq-drill-card__extra">{formatNumber(goodsRows.length)} 个商品</span>
|
||||
</div>
|
||||
<div className="csq-scroll-area">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="goodsId"
|
||||
loading={goodsLoading}
|
||||
columns={goodsColumns}
|
||||
dataSource={pagedGoodsRows}
|
||||
pagination={false}
|
||||
rowClassName={(record) => selectedGoods?.goodsId === record.goodsId ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'}
|
||||
onRow={(record) => ({ onClick: () => { setCustomersPage(1); loadCustomers(record); } })}
|
||||
locale={{ emptyText: <Empty description="暂无商品聊天记录" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
/>
|
||||
</div>
|
||||
{goodsRows.length > PAGE_SIZE && (
|
||||
<div className="csq-drill-card__footer">
|
||||
<Pagination size="small" current={goodsPage} total={goodsRows.length} pageSize={PAGE_SIZE} onChange={setGoodsPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="csq-drill-card">
|
||||
<div className="csq-drill-card__header">
|
||||
<span>{selectedGoodsTitle}</span>
|
||||
<span className="csq-drill-card__extra">{formatNumber(customerRows.length)} 个客户</span>
|
||||
</div>
|
||||
{selectedGoods ? (
|
||||
<div className="csq-scroll-area">
|
||||
<Spin spinning={customersLoading} className="csq-table-spin">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="uid"
|
||||
columns={customerColumns}
|
||||
dataSource={pagedCustomerRows}
|
||||
pagination={false}
|
||||
rowClassName={(record) => selectedCustomer?.uid === record.uid ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'}
|
||||
onRow={(record) => ({ onClick: () => loadMessages(record, selectedGoods) })}
|
||||
locale={{ emptyText: <Empty description="该商品暂无客户聊天记录" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
) : (
|
||||
<div className="csq-scroll-area"><Empty description="先点击左侧商品" image={Empty.PRESENTED_IMAGE_SIMPLE} /></div>
|
||||
)}
|
||||
{customerRows.length > PAGE_SIZE && (
|
||||
<div className="csq-drill-card__footer">
|
||||
<Pagination size="small" current={customersPage} total={customerRows.length} pageSize={PAGE_SIZE} onChange={setCustomersPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="csq-drill-card csq-message-card">
|
||||
<div className="csq-drill-card__header">
|
||||
<span>{selectedCustomerTitle}</span>
|
||||
<span className="csq-drill-card__extra">{formatNumber(messageRows.length)} 条消息</span>
|
||||
</div>
|
||||
{selectedCustomer ? (
|
||||
<div className="csq-scroll-area csq-message-scroll">
|
||||
<Spin spinning={messagesLoading}>
|
||||
<div className="csq-message-list">
|
||||
{pagedMessageRows.length ? pagedMessageRows.map((item, index) => {
|
||||
const imageUrl = getChatImageUrl(item.content);
|
||||
return (
|
||||
<div className={`csq-message csq-message--${getDirectionTone(item.directionLabel)}`} key={`${item.msgId || item.msgTime || index}-${index}`}>
|
||||
<div className="csq-message__meta">
|
||||
<Tag color={isServiceDirection(item.directionLabel) ? 'green' : 'blue'}>{item.directionLabel || '未标记'}</Tag>
|
||||
{item.csname ? <span className="csq-cell-secondary">客服:{item.csname}</span> : null}
|
||||
{item.nickname ? <span className="csq-cell-secondary">买家:{item.nickname}</span> : null}
|
||||
<span className="csq-cell-secondary">{formatText(item.msgTime)}</span>
|
||||
</div>
|
||||
<div className={`csq-message__bubble${imageUrl ? ' csq-message__bubble--image' : ''}`}>
|
||||
{imageUrl ? (
|
||||
<Image className="csq-message__image" src={imageUrl} alt="聊天图片" preview={{ mask: '预览' }} />
|
||||
) : (
|
||||
<>
|
||||
<MessageOutlined />
|
||||
<Paragraph style={{ marginBottom: 0 }} ellipsis={{ rows: 4, expandable: true, symbol: '展开' }}>
|
||||
{formatText(item.content)}
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<Empty description="暂无聊天内容" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
) : (
|
||||
<div className="csq-scroll-area"><Empty description="先点击中间客户" image={Empty.PRESENTED_IMAGE_SIMPLE} /></div>
|
||||
)}
|
||||
{messageRows.length > PAGE_SIZE && (
|
||||
<div className="csq-drill-card__footer">
|
||||
<Pagination size="small" current={messagesPage} total={messageRows.length} pageSize={PAGE_SIZE} onChange={setMessagesPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SycmBusinessPanel>
|
||||
</div>
|
||||
<AiQualityDrawer
|
||||
open={Boolean(aiGoods)}
|
||||
goods={aiGoods}
|
||||
onClose={() => setAiGoods(null)}
|
||||
onCompleted={() => setHistoryRefreshToken((value) => value + 1)}
|
||||
/>
|
||||
<AiQualityHistoryDrawer
|
||||
open={historyOpen}
|
||||
refreshToken={historyRefreshToken}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
/>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerServiceQuality;
|
||||
872
frontend/src/pages/CustomerServiceQuality/style.css
Normal file
872
frontend/src/pages/CustomerServiceQuality/style.css
Normal file
@ -0,0 +1,872 @@
|
||||
.csq-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.csq-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.csq-panel-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.csq-panel__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding: var(--sycm-space-sm) var(--sycm-module-padding) 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csq-panel__heading-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.csq-panel__heading h2 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: var(--sycm-line-height-tight);
|
||||
}
|
||||
|
||||
.csq-panel__heading span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.csq-filter {
|
||||
display: flex;
|
||||
padding: var(--sycm-space-xs) var(--sycm-module-padding);
|
||||
background: var(--sycm-bg-subtle);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csq-error {
|
||||
margin: 0 var(--sycm-module-padding);
|
||||
padding: var(--sycm-space-xs) var(--sycm-space-sm);
|
||||
border: 1px solid var(--sycm-color-danger);
|
||||
border-radius: var(--sycm-radius-md);
|
||||
background: var(--sycm-color-danger-bg);
|
||||
color: var(--sycm-color-danger);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csq-info {
|
||||
padding: var(--sycm-space-xxs) var(--sycm-module-padding);
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csq-drill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr) minmax(0, 1.35fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: calc(100dvh - 146px);
|
||||
max-height: calc(100dvh - 146px);
|
||||
gap: var(--sycm-space-sm);
|
||||
padding: 0 var(--sycm-module-padding) var(--sycm-space-sm);
|
||||
flex: 0 0 calc(100dvh - 146px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.csq-drill-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-lg);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.csq-drill-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--sycm-space-sm);
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
background: var(--sycm-bg-subtle);
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csq-drill-card__extra {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
font-weight: var(--sycm-font-weight-regular);
|
||||
}
|
||||
|
||||
/* 中间可滚动区域 */
|
||||
.csq-scroll-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overscroll-behavior: contain;
|
||||
overflow-y: scroll;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.csq-table-spin,
|
||||
.csq-table-spin > .ant-spin-container {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.csq-table-spin.ant-spin-nested-loading {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 分页固定在底部 */
|
||||
.csq-drill-card__footer {
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
padding: 4px var(--sycm-space-sm);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.csq-drill-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.csq-drill-row:hover > td {
|
||||
background: rgba(32, 98, 230, 0.04) !important;
|
||||
}
|
||||
|
||||
.csq-drill-row--active > td {
|
||||
background: var(--sycm-color-primary-bg) !important;
|
||||
box-shadow: inset 3px 0 0 var(--sycm-color-primary);
|
||||
}
|
||||
|
||||
.csq-goods-cell,
|
||||
.csq-customer-cell {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--sycm-space-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.csq-goods-thumb {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.csq-goods-thumb.is-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--sycm-bg-subtle);
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.csq-cell-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.csq-cell-primary {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.csq-cell-secondary {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.csq-count-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.csq-download-btn {
|
||||
padding-inline: 0;
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.csq-ai-action {
|
||||
color: var(--sycm-color-primary);
|
||||
}
|
||||
|
||||
.csq-ai-action:hover {
|
||||
background: var(--sycm-color-primary-bg) !important;
|
||||
color: var(--sycm-color-primary) !important;
|
||||
}
|
||||
|
||||
.csq-customer-avatar {
|
||||
display: inline-flex;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--sycm-color-primary-bg);
|
||||
color: var(--sycm-color-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.csq-message-card .csq-drill-card__header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csq-message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.csq-message-scroll > .ant-spin-nested-loading,
|
||||
.csq-message-scroll > .ant-spin-nested-loading > .ant-spin-container {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.csq-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.csq-message--outbound {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.csq-message--inbound {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.csq-message__meta {
|
||||
display: flex;
|
||||
max-width: 86%;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.csq-message__bubble {
|
||||
display: flex;
|
||||
max-width: 86%;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
padding: var(--sycm-space-xs) var(--sycm-space-sm);
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: 12px;
|
||||
background: var(--sycm-bg-container);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.csq-message__bubble--image {
|
||||
display: block;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.csq-message__image {
|
||||
display: block;
|
||||
width: 100px;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.csq-message__image .ant-image-img {
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: var(--sycm-radius-md);
|
||||
object-fit: contain;
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.csq-message--outbound .csq-message__bubble {
|
||||
border-color: rgba(32, 98, 230, 0.18);
|
||||
background: var(--sycm-color-primary-soft);
|
||||
}
|
||||
|
||||
.csq-message__bubble .anticon {
|
||||
margin-top: 2px;
|
||||
color: var(--sycm-color-primary);
|
||||
}
|
||||
|
||||
.csq-ai-section {
|
||||
padding: 0 0 var(--sycm-space-lg);
|
||||
margin-bottom: var(--sycm-space-lg);
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.csq-ai-section:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.csq-ai-product {
|
||||
display: flex;
|
||||
gap: var(--sycm-space-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.csq-ai-product__image,
|
||||
.csq-history-detail-product .ant-image-img {
|
||||
flex: 0 0 auto;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-md);
|
||||
background: var(--sycm-bg-subtle);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.csq-ai-product__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.csq-ai-product__content h5 {
|
||||
margin: 0 0 var(--sycm-space-xs);
|
||||
color: var(--sycm-text-strong);
|
||||
}
|
||||
|
||||
.csq-ai-product__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.csq-ai-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-md);
|
||||
margin-bottom: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.csq-ai-section__title > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.csq-ai-section__title h5 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.csq-ai-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-left: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.csq-ai-metrics > div {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: var(--sycm-space-xs) var(--sycm-space-sm);
|
||||
border-right: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.csq-ai-metrics span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.csq-ai-metrics strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.csq-ai-period-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sycm-space-sm);
|
||||
margin-top: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.csq-ai-progress {
|
||||
min-height: 132px;
|
||||
padding: var(--sycm-space-md) 18px;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-left: 3px solid var(--sycm-color-primary);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.csq-ai-progress__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-md);
|
||||
margin-bottom: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.csq-ai-progress__heading > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.csq-ai-progress__heading strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-body);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.csq-ai-progress__heading > span {
|
||||
min-width: 42px;
|
||||
color: var(--sycm-color-primary);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.csq-ai-progress .ant-progress {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.csq-ai-progress__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-md);
|
||||
min-height: 22px;
|
||||
margin-top: var(--sycm-space-xs);
|
||||
}
|
||||
|
||||
.csq-ai-progress__meta > span:last-child {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.csq-ai-progress-error {
|
||||
margin-bottom: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.csq-ai-result__summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr repeat(4, 1fr);
|
||||
gap: var(--sycm-space-sm);
|
||||
margin-bottom: var(--sycm-space-md);
|
||||
padding: var(--sycm-space-sm);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.csq-ai-result__summary > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.csq-ai-result__summary > div:first-child {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sycm-space-xs);
|
||||
}
|
||||
|
||||
.csq-ai-result__summary strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
}
|
||||
|
||||
.csq-ai-findings {
|
||||
margin-bottom: var(--sycm-space-md);
|
||||
padding: var(--sycm-space-sm) var(--sycm-space-md);
|
||||
border-left: 3px solid var(--sycm-color-primary);
|
||||
background: var(--sycm-color-primary-bg);
|
||||
}
|
||||
|
||||
.csq-ai-findings ul {
|
||||
margin: var(--sycm-space-xs) 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.csq-ai-result__detail {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding: 0;
|
||||
color: var(--sycm-text-secondary);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.csq-ai-evidence-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.csq-ai-evidence-list blockquote {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 2px solid var(--sycm-border-light);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.csq-ai-evidence-list blockquote > span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.csq-ai-evidence-list p {
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.csq-ai-result__detail > .ant-descriptions,
|
||||
.csq-ai-result__detail > .csq-ai-result__tags,
|
||||
.csq-ai-result__detail > div:not(.ant-tabs) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csq-ai-result__detail > .ant-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.csq-ai-result__detail > .ant-tabs > .ant-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.csq-ai-result__detail > .ant-tabs > .ant-tabs-content-holder {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding-top: var(--sycm-space-sm);
|
||||
padding-right: 6px;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.csq-ai-detail-pane {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.csq-ai-chat-summary {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sycm-space-xs);
|
||||
}
|
||||
|
||||
.csq-ai-chat-collapse {
|
||||
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer {
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer > summary {
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer > summary::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 1px solid var(--sycm-text-tertiary);
|
||||
border-bottom: 1px solid var(--sycm-text-tertiary);
|
||||
content: '';
|
||||
transform: translateY(-65%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer[open] > summary::after {
|
||||
transform: translateY(-75%) rotate(45deg);
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer-body {
|
||||
padding: 0 0 var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding-right: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer-heading > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer-heading strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-body);
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer-heading .ant-tag {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.csq-ai-chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.csq-ai-chat-message {
|
||||
padding: 8px 10px;
|
||||
border-left: 2px solid var(--sycm-border-light);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.csq-ai-chat-message--service {
|
||||
border-left-color: var(--sycm-color-success);
|
||||
background: var(--sycm-color-success-bg);
|
||||
}
|
||||
|
||||
.csq-ai-chat-message--buyer {
|
||||
border-left-color: var(--sycm-color-primary);
|
||||
background: var(--sycm-color-primary-bg);
|
||||
}
|
||||
|
||||
.csq-ai-chat-message__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.csq-ai-chat-message__meta .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.csq-ai-chat-message p {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.csq-ai-result__recommendation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding: var(--sycm-space-sm) var(--sycm-space-md);
|
||||
border-left: 3px solid var(--sycm-color-primary);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.csq-ai-result__tags,
|
||||
.csq-ai-result__images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.csq-ai-result__images .ant-image-img {
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.csq-history-filter {
|
||||
margin-bottom: var(--sycm-space-md);
|
||||
padding-bottom: var(--sycm-space-md);
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
}
|
||||
|
||||
.csq-history-product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sycm-space-xs);
|
||||
}
|
||||
|
||||
.csq-history-product .ant-image-img {
|
||||
flex: 0 0 auto;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
background: var(--sycm-bg-subtle);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.csq-history-product > div,
|
||||
.csq-history-stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.csq-history-product strong {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-strong);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.csq-history-product span,
|
||||
.csq-history-stack span {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.csq-history-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.csq-history-detail-product {
|
||||
padding-bottom: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.csq-panel__heading-main {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.csq-ai-section__title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.csq-ai-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.csq-ai-result__summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.csq-ai-progress__meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.csq-ai-progress__meta > span:last-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.csq-ai-chat-customer-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.csq-history-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.csq-history-status .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.csq-history-status span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.csq-page {
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.csq-drill-grid {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
flex-basis: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.csq-drill-card {
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
105
frontend/src/pages/DesktopClientDownload/index.jsx
Normal file
105
frontend/src/pages/DesktopClientDownload/index.jsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
AppleOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
WindowsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Skeleton } from 'antd';
|
||||
import { getLatestDesktopRelease } from '../../api/desktopClient';
|
||||
import { SycmBusinessPanel, SycmPage } from '../../components/Common';
|
||||
import './style.css';
|
||||
|
||||
const formatDateTime = (value) => (
|
||||
value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||
);
|
||||
|
||||
const DesktopClientDownload = () => {
|
||||
const [releases, setReleases] = useState({ win32: null, darwin: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadLatest = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [windowsResult, macResult] = await Promise.allSettled([
|
||||
getLatestDesktopRelease('', 'win32', 'x64'),
|
||||
getLatestDesktopRelease('', 'darwin', 'arm64'),
|
||||
]);
|
||||
setReleases({
|
||||
win32: windowsResult.status === 'fulfilled' ? windowsResult.value?.data || null : null,
|
||||
darwin: macResult.status === 'fulfilled' ? macResult.value?.data || null : null,
|
||||
});
|
||||
} catch {
|
||||
setReleases({ win32: null, darwin: null });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLatest();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SycmPage className="desktop-client-download-page">
|
||||
<SycmBusinessPanel
|
||||
title="多多自动平台"
|
||||
extra={(
|
||||
<div className="desktop-client-download-actions">
|
||||
<Button icon={<ReloadOutlined />} onClick={loadLatest} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="desktop-client-download-body">
|
||||
{loading ? (
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
) : releases.win32 || releases.darwin ? (
|
||||
<div className="desktop-client-download-platforms">
|
||||
{[
|
||||
{ key: 'win32', label: 'Windows', icon: <WindowsOutlined />, release: releases.win32 },
|
||||
{ key: 'darwin', label: 'macOS', icon: <AppleOutlined />, release: releases.darwin },
|
||||
].map((item) => (
|
||||
<article className="desktop-client-download-card" key={item.key}>
|
||||
<div className="desktop-client-download-summary">
|
||||
<span className="desktop-client-download-summary__icon">{item.icon}</span>
|
||||
<div className="desktop-client-download-summary__copy">
|
||||
<strong>{item.label} {item.release ? `v${item.release.version}` : '暂未发布'}</strong>
|
||||
<span>
|
||||
{item.release
|
||||
? `发布于 ${formatDateTime(item.release.publishedAt || item.release.createdAt)} · ${item.release.architecture || '--'}`
|
||||
: `尚无可下载的 ${item.label} 安装包`}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
href={item.release?.downloadUrl}
|
||||
disabled={!item.release}
|
||||
>
|
||||
下载{item.label}版
|
||||
</Button>
|
||||
</div>
|
||||
{item.release?.releaseNotes ? (
|
||||
<div className="desktop-client-download-notes">
|
||||
<span className="desktop-client-download-notes__label">更新说明</span>
|
||||
<p>{item.release.releaseNotes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂未发布可下载的客户端版本"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SycmBusinessPanel>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopClientDownload;
|
||||
112
frontend/src/pages/DesktopClientDownload/style.css
Normal file
112
frontend/src/pages/DesktopClientDownload/style.css
Normal file
@ -0,0 +1,112 @@
|
||||
.desktop-client-download-actions {
|
||||
display: flex;
|
||||
gap: var(--sycm-space-xs);
|
||||
}
|
||||
|
||||
.desktop-client-download-body {
|
||||
min-width: 0;
|
||||
padding: var(--sycm-module-padding);
|
||||
}
|
||||
|
||||
.desktop-client-download-platforms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sycm-page-section-gap);
|
||||
}
|
||||
|
||||
.desktop-client-download-card {
|
||||
min-width: 0;
|
||||
padding: var(--sycm-space-md);
|
||||
border: 1px solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-md);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.desktop-client-download-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.desktop-client-download-summary__icon {
|
||||
display: inline-flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--sycm-border-base);
|
||||
border-radius: var(--sycm-radius-lg);
|
||||
background: var(--sycm-bg-subtle);
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.desktop-client-download-summary__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.desktop-client-download-summary > .ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.desktop-client-download-summary__copy strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.desktop-client-download-summary__copy span {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.desktop-client-download-notes {
|
||||
display: grid;
|
||||
gap: var(--sycm-space-sm);
|
||||
margin-top: var(--sycm-module-header-gap);
|
||||
padding: var(--sycm-space-md);
|
||||
border: 1px solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-lg);
|
||||
background: var(--sycm-bg-page-soft);
|
||||
}
|
||||
|
||||
.desktop-client-download-notes__label {
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
}
|
||||
|
||||
.desktop-client-download-notes p {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-primary);
|
||||
font-size: var(--sycm-font-size-body);
|
||||
line-height: 22px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.desktop-client-download-platforms {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.desktop-client-download-summary {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.desktop-client-download-summary > .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
261
frontend/src/pages/DesktopClientReleases/index.jsx
Normal file
261
frontend/src/pages/DesktopClientReleases/index.jsx
Normal file
@ -0,0 +1,261 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AppleOutlined,
|
||||
CloudUploadOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
WindowsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Progress,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
getDesktopReleases,
|
||||
publishDesktopRelease,
|
||||
} from '../../api/desktopClient';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import './style.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
const formatFileSize = (value) => {
|
||||
const bytes = Number(value || 0);
|
||||
if (!bytes) return '--';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatDateTime = (value) => (
|
||||
value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||
);
|
||||
|
||||
const inferVersion = (fileName) => (
|
||||
String(fileName || '').match(/pdd-auto-sale-(\d+\.\d+\.\d+)(?:-[^.]+)?\.(?:exe|dmg)$/i)?.[1] || ''
|
||||
);
|
||||
|
||||
const inferInstallerMeta = (fileName) => {
|
||||
const normalized = String(fileName || '').toLowerCase();
|
||||
const platform = normalized.endsWith('.dmg') ? 'darwin' : 'win32';
|
||||
const architecture = normalized.match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/)?.[1]
|
||||
|| (platform === 'darwin' ? 'arm64' : 'x64');
|
||||
return {
|
||||
platform,
|
||||
architecture,
|
||||
label: platform === 'darwin' ? 'macOS' : 'Windows',
|
||||
};
|
||||
};
|
||||
|
||||
const platformLabel = (platform, architecture) => (
|
||||
`${platform === 'darwin' ? 'macOS' : 'Windows'} · ${architecture || (platform === 'darwin' ? 'arm64' : 'x64')}`
|
||||
);
|
||||
|
||||
const DesktopClientReleases = () => {
|
||||
const [releases, setReleases] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [installer, setInstaller] = useState(null);
|
||||
const [version, setVersion] = useState('');
|
||||
const [releaseNotes, setReleaseNotes] = useState('');
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const latestWindows = releases.find((release) => release.platform === 'win32' && release.isLatest) || null;
|
||||
const latestMac = releases.find((release) => release.platform === 'darwin' && release.isLatest) || null;
|
||||
const installerMeta = useMemo(() => inferInstallerMeta(installer?.name), [installer]);
|
||||
|
||||
const loadReleases = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getDesktopReleases();
|
||||
setReleases(response.data || []);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || error.message || '发布记录加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadReleases();
|
||||
}, []);
|
||||
|
||||
const fileList = useMemo(() => (
|
||||
installer ? [{ uid: installer.uid || installer.name, name: installer.name, status: 'done', originFileObj: installer }] : []
|
||||
), [installer]);
|
||||
|
||||
const handleFile = (file) => {
|
||||
if (!/\.(exe|dmg)$/i.test(String(file.name || ''))) {
|
||||
message.warning('请选择 Windows(.exe)或 macOS(.dmg)安装包');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
setInstaller(file);
|
||||
const inferredVersion = inferVersion(file.name);
|
||||
if (inferredVersion) setVersion(inferredVersion);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!installer) {
|
||||
message.warning('请先选择桌面客户端安装包');
|
||||
return;
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version.trim())) {
|
||||
message.warning('版本号格式必须为 x.y.z,例如 0.0.9');
|
||||
return;
|
||||
}
|
||||
|
||||
setPublishing(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
const response = await publishDesktopRelease({
|
||||
version: version.trim(),
|
||||
releaseNotes: releaseNotes.trim(),
|
||||
installer,
|
||||
platform: installerMeta.platform,
|
||||
architecture: installerMeta.architecture,
|
||||
onUploadProgress: (event) => {
|
||||
if (event.total) setUploadProgress(Math.round((event.loaded / event.total) * 100));
|
||||
},
|
||||
});
|
||||
message.success(response.message || `桌面客户端 v${version.trim()} 已发布`);
|
||||
setInstaller(null);
|
||||
setVersion('');
|
||||
setReleaseNotes('');
|
||||
setUploadProgress(100);
|
||||
await loadReleases();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || error.message || '桌面客户端发布失败');
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SycmPage className="desktop-release-page">
|
||||
<header className="desktop-release-toolbar">
|
||||
<div>
|
||||
<Title level={2}>客户端版本</Title>
|
||||
<Text type="secondary">Windows 与 macOS 最新安装包</Text>
|
||||
</div>
|
||||
<Button
|
||||
className="desktop-release-refresh"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={loading}
|
||||
onClick={loadReleases}
|
||||
aria-label="刷新最新安装包"
|
||||
title="刷新最新安装包"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="desktop-release-overview-grid">
|
||||
{[
|
||||
{ key: 'win32', label: 'Windows', icon: <WindowsOutlined />, release: latestWindows },
|
||||
{ key: 'darwin', label: 'macOS', icon: <AppleOutlined />, release: latestMac },
|
||||
].map((item) => (
|
||||
<article className={`desktop-release-overview desktop-release-overview--${item.key}`} key={item.key}>
|
||||
<div className="desktop-release-overview__topline">
|
||||
<span className="desktop-release-overview__icon">{item.icon}</span>
|
||||
<div>
|
||||
<span>最新安装包</span>
|
||||
<strong>{item.label}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<strong className="desktop-release-overview__version">
|
||||
{item.release ? `v${item.release.version}` : '暂未发布'}
|
||||
</strong>
|
||||
<p>{item.release?.releaseNotes || `尚无可下载的 ${item.label} 安装包`}</p>
|
||||
<div className="desktop-release-overview__meta">
|
||||
<span>{item.release ? platformLabel(item.release.platform, item.release.architecture) : item.label}</span>
|
||||
<span>{formatFileSize(item.release?.fileSize)}</span>
|
||||
<span>{formatDateTime(item.release?.publishedAt)}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
href={item.release?.downloadUrl}
|
||||
disabled={!item.release}
|
||||
>
|
||||
下载 {item.label}
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="desktop-release-publish-panel">
|
||||
<div className="desktop-release-section-heading">
|
||||
<span className="desktop-release-section-heading__icon"><CloudUploadOutlined /></span>
|
||||
<div>
|
||||
<Title level={3}>发布最新安装包</Title>
|
||||
<Text type="secondary">上传后自动替换对应平台的下载入口</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="desktop-release-publish-grid">
|
||||
<Dragger
|
||||
accept=".exe,.dmg"
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
beforeUpload={handleFile}
|
||||
onRemove={() => {
|
||||
setInstaller(null);
|
||||
return true;
|
||||
}}
|
||||
disabled={publishing}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><CloudUploadOutlined /></p>
|
||||
<p className="ant-upload-text">拖入 Windows 或 macOS 安装包</p>
|
||||
<p className="ant-upload-hint">支持 .exe、.dmg,最大 300 MB</p>
|
||||
</Dragger>
|
||||
|
||||
<div className="desktop-release-form">
|
||||
<label>
|
||||
<span>识别平台</span>
|
||||
<Input
|
||||
value={installer ? platformLabel(installerMeta.platform, installerMeta.architecture) : '选择安装包后自动识别'}
|
||||
disabled
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>版本号</span>
|
||||
<Input
|
||||
value={version}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
placeholder="例如 0.0.9"
|
||||
prefix="v"
|
||||
disabled={publishing}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>更新说明</span>
|
||||
<TextArea
|
||||
value={releaseNotes}
|
||||
onChange={(event) => setReleaseNotes(event.target.value)}
|
||||
placeholder="说明本次新增功能、修复内容及操作变化"
|
||||
autoSize={{ minRows: 4, maxRows: 7 }}
|
||||
disabled={publishing}
|
||||
/>
|
||||
</label>
|
||||
{publishing ? (
|
||||
<Progress percent={uploadProgress} status="active" size="small" />
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={publishing}
|
||||
onClick={handlePublish}
|
||||
disabled={!installer}
|
||||
>
|
||||
发布{installer ? ` ${installerMeta.label}` : ''}最新版
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopClientReleases;
|
||||
299
frontend/src/pages/DesktopClientReleases/style.css
Normal file
299
frontend/src/pages/DesktopClientReleases/style.css
Normal file
@ -0,0 +1,299 @@
|
||||
.desktop-release-page {
|
||||
gap: 18px;
|
||||
background: var(--sycm-bg-page);
|
||||
}
|
||||
|
||||
.desktop-release-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.desktop-release-toolbar h2 {
|
||||
margin: 0 0 3px;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.desktop-release-toolbar .ant-typography-secondary {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.desktop-release-refresh.ant-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.desktop-release-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.desktop-release-overview {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 222px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 12px 20px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--sycm-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-bg-container);
|
||||
box-shadow: var(--sycm-shadow-card);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.desktop-release-overview:hover {
|
||||
border-color: var(--sycm-color-primary-border);
|
||||
box-shadow: var(--sycm-shadow-card);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.desktop-release-overview__topline {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.desktop-release-overview__topline > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.desktop-release-overview__topline span:not(.desktop-release-overview__icon) {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desktop-release-overview__topline strong {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.desktop-release-overview__icon {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #eaf2ff;
|
||||
color: #287cf3;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.desktop-release-overview--darwin .desktop-release-overview__icon {
|
||||
background: #f1f3f5;
|
||||
color: #20242a;
|
||||
}
|
||||
|
||||
.desktop-release-overview__version {
|
||||
align-self: center;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: 26px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.desktop-release-overview > p {
|
||||
display: -webkit-box;
|
||||
min-height: 40px;
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.desktop-release-overview__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktop-release-overview > .ant-btn {
|
||||
align-self: end;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.desktop-release-publish-panel {
|
||||
padding: 20px 22px 22px;
|
||||
border: 1px solid var(--sycm-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-bg-container);
|
||||
box-shadow: var(--sycm-shadow-card);
|
||||
}
|
||||
|
||||
.desktop-release-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.desktop-release-section-heading__icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 36px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-color-primary-bg);
|
||||
color: var(--sycm-color-primary);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.desktop-release-section-heading h3 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.desktop-release-section-heading .ant-typography-secondary {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.85fr) minmax(420px, 1.15fr);
|
||||
align-items: stretch;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid .ant-upload-wrapper,
|
||||
.desktop-release-publish-grid .ant-upload-drag {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid .ant-upload-drag {
|
||||
min-height: 230px;
|
||||
border-color: var(--sycm-border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid .ant-upload-drag:hover {
|
||||
border-color: var(--sycm-color-primary);
|
||||
background: var(--sycm-color-primary-bg);
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid .ant-upload-drag-icon {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid .ant-upload-drag-icon .anticon {
|
||||
color: var(--sycm-color-primary);
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid .ant-upload-text {
|
||||
margin: 0 0 4px;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: 14px;
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.desktop-release-publish-grid .ant-upload-hint {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desktop-release-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: 14px 16px;
|
||||
}
|
||||
|
||||
.desktop-release-form label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.desktop-release-form label:nth-of-type(3),
|
||||
.desktop-release-form .ant-progress,
|
||||
.desktop-release-form > .ant-btn {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.desktop-release-form label > span {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.desktop-release-form .ant-input,
|
||||
.desktop-release-form .ant-input-affix-wrapper {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.desktop-release-form > .ant-btn {
|
||||
justify-self: end;
|
||||
min-width: 154px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.desktop-release-overview-grid,
|
||||
.desktop-release-publish-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.desktop-release-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.desktop-release-overview__version,
|
||||
.desktop-release-overview > p,
|
||||
.desktop-release-overview__meta,
|
||||
.desktop-release-overview > .ant-btn {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.desktop-release-overview__meta {
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.desktop-release-overview > .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop-release-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.desktop-release-form label,
|
||||
.desktop-release-form label:nth-of-type(3),
|
||||
.desktop-release-form > .ant-btn {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.desktop-release-form > .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BarChartOutlined,
|
||||
BulbOutlined,
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
LineChartOutlined,
|
||||
RiseOutlined,
|
||||
FullscreenOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
TableOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, DatePicker, Select, Skeleton } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
@ -24,7 +27,12 @@ import {
|
||||
SycmSimpleTable,
|
||||
SycmSparkline,
|
||||
} from '../../components/Common';
|
||||
import { getDashboardMeta, getDashboardSummary } from '../../api/dashboard';
|
||||
import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis';
|
||||
import {
|
||||
getStoreCheckupHomeCompensation,
|
||||
getStoreCheckupHomeReviews,
|
||||
getStoreCheckupHomeSummary,
|
||||
} from '../../api/storeCheckup';
|
||||
import './stylePreview.css';
|
||||
|
||||
const overviewMetrics = [
|
||||
@ -158,7 +166,256 @@ const activateWithKeyboard = (callback) => (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatStoreScore = (value, digits = 2) => (
|
||||
value === null || value === undefined || !Number.isFinite(Number(value))
|
||||
? '暂无'
|
||||
: Number(value).toFixed(digits)
|
||||
);
|
||||
|
||||
const StoreCheckupSummaryCard = ({ data, loading, error, onOpen }) => {
|
||||
const hasRisks = Number(data?.riskSignalCount || 0) > 0;
|
||||
const starChange = Number(data?.starChange);
|
||||
const hasStarChange = data?.starChange !== null
|
||||
&& data?.starChange !== undefined
|
||||
&& Number.isFinite(starChange);
|
||||
const isFlatStarChange = hasStarChange && Math.abs(starChange) < 0.005;
|
||||
|
||||
return (
|
||||
<article className="preview-store-health-card">
|
||||
<header className="preview-store-health-card__header">
|
||||
<div>
|
||||
<h2>店铺体检</h2>
|
||||
<span>{data?.date ? `数据截至 ${data.date}` : '经营健康摘要'}</span>
|
||||
</div>
|
||||
<button type="button" onClick={onOpen} title="进入店铺体检">
|
||||
查看详情 <RightOutlined />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loading && !data ? (
|
||||
<Skeleton active paragraph={{ rows: 9 }} title={false} />
|
||||
) : error && !data ? (
|
||||
<div className="preview-store-health-card__error">
|
||||
<WarningOutlined />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="preview-store-health-card__headline">
|
||||
<div className="preview-store-health-card__star">
|
||||
<span>综合体验星级</span>
|
||||
<div>
|
||||
<strong>{formatStoreScore(data?.averageStar, 1)}</strong>
|
||||
<small>星</small>
|
||||
{hasStarChange ? (
|
||||
<em className={isFlatStarChange ? 'is-flat' : starChange < 0 ? 'is-down' : 'is-up'}>
|
||||
{isFlatStarChange ? '持平' : `${starChange > 0 ? '+' : ''}${starChange.toFixed(2)}`}
|
||||
</em>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-store-health-card__coverage">
|
||||
<span>已覆盖店铺</span>
|
||||
<strong>{numberFormatter.format(Number(data?.shopCount || 0))}</strong>
|
||||
<em>{data?.coverageModules || 0}/{data?.totalModules || 4} 类数据已接入</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-store-health-card__section-title">
|
||||
<span>风险信号</span>
|
||||
<em className={hasRisks ? 'is-attention' : 'is-healthy'}>
|
||||
{hasRisks ? `${data?.riskTypeCount || 0} 类 · ${numberFormatter.format(Number(data?.riskSignalCount || 0))} 项` : '状态良好'}
|
||||
</em>
|
||||
</div>
|
||||
<div className="preview-store-health-card__risk-grid">
|
||||
{(data?.riskItems || []).map((item) => (
|
||||
<div key={item.key} className={Number(item.value) > 0 ? `is-${item.tone}` : ''}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{numberFormatter.format(Number(item.value || 0))}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="preview-store-health-card__section-title">
|
||||
<span>服务表现</span>
|
||||
<em>4项核心评分</em>
|
||||
</div>
|
||||
<div className="preview-store-health-card__score-grid">
|
||||
{(data?.serviceScores || []).map((item) => (
|
||||
<div key={item.key}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{formatStoreScore(item.value)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewReputationSection = ({ data, loading, error }) => {
|
||||
const distribution = [...(data?.distribution || [])].sort((left, right) => right.score - left.score);
|
||||
const badKeywordTotal = (data?.badKeywords || []).reduce((sum, item) => sum + Number(item.count || 0), 0);
|
||||
|
||||
return (
|
||||
<section className="preview-review-section" aria-label="评价口碑">
|
||||
<header className="preview-review-section__header">
|
||||
<div>
|
||||
<h2>评价口碑</h2>
|
||||
<p>真实买家评价的评分表现与差评问题分布</p>
|
||||
</div>
|
||||
<span>{data?.startDate && data?.endDate ? `${data.startDate} 至 ${data.endDate}` : '跟随当前统计周期'}</span>
|
||||
</header>
|
||||
|
||||
{loading && !data ? (
|
||||
<Skeleton active paragraph={{ rows: 5 }} title={false} />
|
||||
) : error && !data ? (
|
||||
<Alert type="error" showIcon message={error} />
|
||||
) : (
|
||||
<div className="preview-review-section__body">
|
||||
<div className="preview-review-kpis">
|
||||
<div className="preview-review-kpis__primary">
|
||||
<span>好评率</span>
|
||||
<strong>{Number(data?.goodReviewRate || 0).toFixed(2)}<small>%</small></strong>
|
||||
<em>4-5 星评价 {numberFormatter.format(Number(data?.goodReviewCount || 0))} 条</em>
|
||||
</div>
|
||||
<div className="preview-review-kpis__grid">
|
||||
<div><span>评价总数</span><strong>{numberFormatter.format(Number(data?.totalReviewCount || 0))}</strong></div>
|
||||
<div className="is-risk"><span>差评数</span><strong>{numberFormatter.format(Number(data?.badReviewCount || 0))}</strong></div>
|
||||
<div><span>平均评分</span><strong>{data?.averageScore == null ? '暂无' : Number(data.averageScore).toFixed(2)}</strong></div>
|
||||
<div><span>覆盖商品</span><strong>{numberFormatter.format(Number(data?.reviewedGoodsCount || 0))}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-review-distribution">
|
||||
<div className="preview-review-subtitle">
|
||||
<h3>评分分布</h3>
|
||||
<span>共覆盖 {numberFormatter.format(Number(data?.reviewedShopCount || 0))} 家店铺</span>
|
||||
</div>
|
||||
<div className="preview-review-stars">
|
||||
{distribution.map((item) => (
|
||||
<div key={item.score}>
|
||||
<span>{item.score} 星</span>
|
||||
<div><i style={{ width: `${Math.max(0, Math.min(100, Number(item.rate || 0)))}%` }} /></div>
|
||||
<strong>{numberFormatter.format(Number(item.count || 0))}</strong>
|
||||
<em>{Number(item.rate || 0).toFixed(1)}%</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-review-keywords">
|
||||
<div className="preview-review-subtitle">
|
||||
<h3>差评分布</h3>
|
||||
<span>1-3 星评价问题 TOP 5</span>
|
||||
</div>
|
||||
{(data?.badKeywords || []).length ? (
|
||||
<div className="preview-review-keyword-list">
|
||||
{data.badKeywords.map((item, index) => (
|
||||
<div key={item.keyword}>
|
||||
<b>{index + 1}</b>
|
||||
<span>{item.keyword}</span>
|
||||
<div><i style={{ width: `${badKeywordTotal ? Number(item.count || 0) * 100 / badKeywordTotal : 0}%` }} /></div>
|
||||
<strong>{numberFormatter.format(Number(item.count || 0))}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="preview-review-keywords__empty">当前周期暂无明确差评标签</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const CompensationRiskSection = ({ data, loading, error }) => {
|
||||
const reasons = data?.reasonDistribution || [];
|
||||
const goods = data?.highCompensationGoods || [];
|
||||
const maxReasonAmount = Math.max(...reasons.map((item) => Number(item.amount || 0)), 1);
|
||||
const formatCompensationAmount = (value) => `¥${moneyFormatter.format(Number(value || 0))}`;
|
||||
|
||||
return (
|
||||
<section className="preview-compensation-section" aria-label="赔付风险">
|
||||
<header className="preview-compensation-section__header">
|
||||
<div>
|
||||
<h2>赔付风险</h2>
|
||||
<p>赔付订单、赔付金额及高赔付商品的经营风险分布</p>
|
||||
</div>
|
||||
<span>{data?.startDate && data?.endDate ? `${data.startDate} 至 ${data.endDate}` : '跟随当前统计周期'}</span>
|
||||
</header>
|
||||
|
||||
{loading && !data ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={false} />
|
||||
) : error && !data ? (
|
||||
<Alert type="error" showIcon message={error} />
|
||||
) : (
|
||||
<div className="preview-compensation-section__body">
|
||||
<div className="preview-compensation-kpis">
|
||||
<div className="preview-compensation-kpis__primary">
|
||||
<span>赔付总额</span>
|
||||
<strong>{formatCompensationAmount(data?.totalAmount)}</strong>
|
||||
<em>{numberFormatter.format(Number(data?.selectedRecordCount || 0))} 条赔付记录</em>
|
||||
</div>
|
||||
<div className="preview-compensation-kpis__grid">
|
||||
<div><span>赔付单量</span><strong>{numberFormatter.format(Number(data?.compensationCount || 0))}</strong></div>
|
||||
<div><span>平均每单</span><strong>{formatCompensationAmount(data?.averageAmount)}</strong></div>
|
||||
<div><span>涉及商品ID</span><strong>{numberFormatter.format(Number(data?.affectedGoodsCount || 0))}</strong></div>
|
||||
<div><span>最高商品赔付</span><strong>{formatCompensationAmount(goods[0]?.amount)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-compensation-reasons">
|
||||
<div className="preview-compensation-subtitle">
|
||||
<h3>赔付原因分布</h3>
|
||||
<span>按赔付金额排序</span>
|
||||
</div>
|
||||
{reasons.length ? (
|
||||
<div className="preview-compensation-reason-list">
|
||||
{reasons.map((item) => (
|
||||
<div key={item.reason}>
|
||||
<span title={item.reason}>{item.reason}</span>
|
||||
<div><i style={{ width: `${Math.min(100, Number(item.amount || 0) * 100 / maxReasonAmount)}%` }} /></div>
|
||||
<strong>{formatCompensationAmount(item.amount)}</strong>
|
||||
<em>{numberFormatter.format(Number(item.orderCount || 0))} 单</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="preview-compensation-empty">当前周期暂无赔付记录</div>}
|
||||
</div>
|
||||
|
||||
<div className="preview-compensation-goods">
|
||||
<div className="preview-compensation-subtitle">
|
||||
<h3>高赔付商品ID</h3>
|
||||
<span>按赔付总额 TOP 6</span>
|
||||
</div>
|
||||
{goods.length ? (
|
||||
<div className="preview-compensation-goods-list">
|
||||
{goods.map((item, index) => (
|
||||
<div key={item.goodsId}>
|
||||
<b>{index + 1}</b>
|
||||
<div className="preview-compensation-goods-list__info">
|
||||
<strong>{item.goodsId}</strong>
|
||||
<span title={item.goodsName}>{item.goodsName || '商品名称暂无'}</span>
|
||||
</div>
|
||||
<div className="preview-compensation-goods-list__amount">
|
||||
<strong>{formatCompensationAmount(item.amount)}</strong>
|
||||
<span>{numberFormatter.format(Number(item.orderCount || 0))} 单</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="preview-compensation-empty">当前周期暂无高赔付商品</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const DevelopmentStylePreview = () => {
|
||||
const navigate = useNavigate();
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(0);
|
||||
const [selectedMetricIndex, setSelectedMetricIndex] = useState(0);
|
||||
const [selectedGrowthIndex, setSelectedGrowthIndex] = useState(0);
|
||||
@ -174,13 +431,27 @@ const DevelopmentStylePreview = () => {
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [dashboardLoading, setDashboardLoading] = useState(true);
|
||||
const [dashboardError, setDashboardError] = useState('');
|
||||
const [storeCheckupSummary, setStoreCheckupSummary] = useState(null);
|
||||
const [storeCheckupLoading, setStoreCheckupLoading] = useState(true);
|
||||
const [storeCheckupError, setStoreCheckupError] = useState('');
|
||||
const [reviewSummary, setReviewSummary] = useState(null);
|
||||
const [reviewLoading, setReviewLoading] = useState(true);
|
||||
const [reviewError, setReviewError] = useState('');
|
||||
const [compensationSummary, setCompensationSummary] = useState(null);
|
||||
const [compensationLoading, setCompensationLoading] = useState(true);
|
||||
const [compensationError, setCompensationError] = useState('');
|
||||
const [shopName, setShopName] = useState('');
|
||||
const [shopOptions, setShopOptions] = useState([{ value: '', label: '全部店铺' }]);
|
||||
const [shopOptionsLoading, setShopOptionsLoading] = useState(true);
|
||||
const homePeriodDays = { 日: 1, 周: 7, 月: 30 }[activePeriod] || 7;
|
||||
const homeDataEndDate = customEndDate || storeCheckupSummary?.date || dashboard?.latestDate || '';
|
||||
const homeDataStartDate = customStartDate || (
|
||||
homeDataEndDate ? dayjs(homeDataEndDate).subtract(homePeriodDays - 1, 'day').format('YYYY-MM-DD') : ''
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getDashboardMeta().then((response) => {
|
||||
getProductAnalysisMeta().then((response) => {
|
||||
if (!active) return;
|
||||
const shops = Array.isArray(response?.data?.shops) ? response.data.shops : [];
|
||||
setShopOptions([
|
||||
@ -202,7 +473,7 @@ const DevelopmentStylePreview = () => {
|
||||
const params = customStartDate && customEndDate
|
||||
? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined }
|
||||
: { period: PERIOD_MAP[activePeriod] || 'week', shopName: shopName || undefined };
|
||||
getDashboardSummary(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
|
||||
getDevelopmentDashboard(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
|
||||
const payload = response?.data || {};
|
||||
setDashboard(payload);
|
||||
}).catch((error) => {
|
||||
@ -215,9 +486,74 @@ const DevelopmentStylePreview = () => {
|
||||
return () => controller.abort();
|
||||
}, [activePeriod, customStartDate, customEndDate, shopName]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
getStoreCheckupHomeSummary({ shopName: shopName || undefined }, {
|
||||
signal: controller.signal,
|
||||
timeout: DASHBOARD_REQUEST_TIMEOUT_MS,
|
||||
}).then((response) => {
|
||||
setStoreCheckupSummary(response?.data || null);
|
||||
}).catch((error) => {
|
||||
if (error?.code !== 'ERR_CANCELED') {
|
||||
setStoreCheckupError(error?.response?.data?.error || error.message || '店铺体检摘要加载失败');
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setStoreCheckupLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [shopName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!homeDataStartDate || !homeDataEndDate) return undefined;
|
||||
const controller = new AbortController();
|
||||
getStoreCheckupHomeReviews({
|
||||
startDate: homeDataStartDate,
|
||||
endDate: homeDataEndDate,
|
||||
shopName: shopName || undefined,
|
||||
}, {
|
||||
signal: controller.signal,
|
||||
timeout: DASHBOARD_REQUEST_TIMEOUT_MS,
|
||||
}).then((response) => {
|
||||
setReviewSummary(response?.data || null);
|
||||
}).catch((error) => {
|
||||
if (error?.code !== 'ERR_CANCELED') {
|
||||
setReviewError(error?.response?.data?.error || error.message || '评价口碑加载失败');
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setReviewLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [homeDataStartDate, homeDataEndDate, shopName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!homeDataStartDate || !homeDataEndDate) return undefined;
|
||||
const controller = new AbortController();
|
||||
getStoreCheckupHomeCompensation({
|
||||
startDate: homeDataStartDate,
|
||||
endDate: homeDataEndDate,
|
||||
shopName: shopName || undefined,
|
||||
}, {
|
||||
signal: controller.signal,
|
||||
timeout: DASHBOARD_REQUEST_TIMEOUT_MS,
|
||||
}).then((response) => {
|
||||
setCompensationSummary(response?.data || null);
|
||||
}).catch((error) => {
|
||||
if (error?.code !== 'ERR_CANCELED') {
|
||||
setCompensationError(error?.response?.data?.error || error.message || '赔付风险加载失败');
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setCompensationLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [homeDataStartDate, homeDataEndDate, shopName]);
|
||||
|
||||
const applyDateRange = (startDate, endDate, period) => {
|
||||
setDashboardLoading(true);
|
||||
setDashboardError('');
|
||||
setReviewLoading(true);
|
||||
setReviewError('');
|
||||
setCompensationLoading(true);
|
||||
setCompensationError('');
|
||||
setActivePeriod(period);
|
||||
setCustomStartDate(startDate.format('YYYY-MM-DD'));
|
||||
setCustomEndDate(endDate.format('YYYY-MM-DD'));
|
||||
@ -239,6 +575,10 @@ const DevelopmentStylePreview = () => {
|
||||
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) {
|
||||
setDashboardLoading(true);
|
||||
setDashboardError('');
|
||||
setReviewLoading(true);
|
||||
setReviewError('');
|
||||
setCompensationLoading(true);
|
||||
setCompensationError('');
|
||||
setActivePeriod('周');
|
||||
setPeriodPickerType('周');
|
||||
setCustomStartDate('');
|
||||
@ -251,6 +591,12 @@ const DevelopmentStylePreview = () => {
|
||||
setShopName(value || '');
|
||||
setDashboardLoading(true);
|
||||
setDashboardError('');
|
||||
setStoreCheckupLoading(true);
|
||||
setStoreCheckupError('');
|
||||
setReviewLoading(true);
|
||||
setReviewError('');
|
||||
setCompensationLoading(true);
|
||||
setCompensationError('');
|
||||
};
|
||||
const displayedDateRange = customStartDate && customEndDate
|
||||
? [dayjs(customStartDate), dayjs(customEndDate)]
|
||||
@ -322,6 +668,15 @@ const DevelopmentStylePreview = () => {
|
||||
{ label: '收藏人数', value: formatDashboardValue(dashboardMetricMap.get('favoriteCount')?.value, 'integer'), sub: '产生收藏行为' },
|
||||
{ label: '支付买家', value: formatDashboardValue(dashboardMetricMap.get('buyerCount')?.value, 'integer'), sub: '完成支付买家' },
|
||||
];
|
||||
const changedMetrics = (dashboard?.metrics || []).filter((metric) => Number.isFinite(Number(metric.change)));
|
||||
const decliningMetricCount = changedMetrics.filter((metric) => Number(metric.change) < 0).length;
|
||||
const growingMetricCount = changedMetrics.filter((metric) => Number(metric.change) >= 0).length;
|
||||
const diagnosisTitle = decliningMetricCount > 0
|
||||
? `${decliningMetricCount} 项核心指标环比下降`
|
||||
: '核心经营指标保持稳定';
|
||||
const diagnosisDescription = decliningMetricCount > 0
|
||||
? '建议优先检查访客、支付订单和收藏变化,再结合店铺体检中的售后及服务风险定位原因。'
|
||||
: '当前周期核心经营指标未出现明显下滑,继续关注支付转化和店铺服务风险。';
|
||||
const suggestion = suggestions[suggestionIndex];
|
||||
const moveSuggestion = (offset) => {
|
||||
setSuggestionIndex((current) => (current + offset + suggestions.length) % suggestions.length);
|
||||
@ -440,8 +795,28 @@ const DevelopmentStylePreview = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="preview-side-column">
|
||||
<StoreCheckupSummaryCard
|
||||
data={storeCheckupSummary}
|
||||
loading={storeCheckupLoading}
|
||||
error={storeCheckupError}
|
||||
onOpen={() => navigate('/planning/operation/store-checkup')}
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<ReviewReputationSection
|
||||
data={reviewSummary}
|
||||
loading={reviewLoading}
|
||||
error={reviewError}
|
||||
/>
|
||||
|
||||
<CompensationRiskSection
|
||||
data={compensationSummary}
|
||||
loading={compensationLoading}
|
||||
error={compensationError}
|
||||
/>
|
||||
|
||||
<section className="preview-growth-section">
|
||||
<header className="preview-growth-section__header">
|
||||
<h2>增长因子</h2>
|
||||
@ -542,6 +917,53 @@ const DevelopmentStylePreview = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="preview-lower-grid">
|
||||
<article className="preview-card preview-health-risk-card">
|
||||
<div className="preview-card__header">
|
||||
<div>
|
||||
<h2>售后与服务风险</h2>
|
||||
<p>来自店铺体检最新数据</p>
|
||||
</div>
|
||||
<WarningOutlined />
|
||||
</div>
|
||||
{storeCheckupLoading && !storeCheckupSummary ? (
|
||||
<div className="preview-health-risk-loading"><Skeleton active paragraph={{ rows: 4 }} title={false} /></div>
|
||||
) : (
|
||||
<div className="preview-health-risk-list">
|
||||
{(storeCheckupSummary?.riskItems || []).map((item) => (
|
||||
<div key={item.key}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={Number(item.value) > 0 ? `is-${item.tone}` : ''}>{numberFormatter.format(Number(item.value || 0))}</strong>
|
||||
<em>{Number(item.value) > 0 ? '需关注' : '正常'}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="preview-card preview-diagnosis-card">
|
||||
<div className="preview-card__header">
|
||||
<div>
|
||||
<h2>经营诊断</h2>
|
||||
<p>经营指标与店铺风险联合判断</p>
|
||||
</div>
|
||||
<CheckCircleOutlined />
|
||||
</div>
|
||||
<div className="preview-diagnosis-summary">
|
||||
<span>增长指标 {growingMetricCount}</span>
|
||||
<span>下滑指标 {decliningMetricCount}</span>
|
||||
<span>风险类型 {storeCheckupSummary?.riskTypeCount || 0}</span>
|
||||
<span>覆盖店铺 {storeCheckupSummary?.shopCount || 0}</span>
|
||||
</div>
|
||||
<div className="preview-diagnosis-issue">
|
||||
<span>本周期诊断</span>
|
||||
<h3>{diagnosisTitle}</h3>
|
||||
<p>{diagnosisDescription}</p>
|
||||
<button type="button" onClick={() => navigate('/planning/operation/store-checkup')}>查看店铺体检</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="preview-card preview-journey-card">
|
||||
<div className="preview-card__header">
|
||||
<div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,6 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Result,
|
||||
Row,
|
||||
Space,
|
||||
@ -26,7 +25,6 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
bootstrapInstallation,
|
||||
bootstrapRemoteInstallation,
|
||||
getInstallStatus,
|
||||
testInstallSystemDatabase,
|
||||
} from '../../api/install';
|
||||
@ -42,7 +40,6 @@ const { Title, Text } = Typography;
|
||||
const Install = () => {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [remoteForm] = Form.useForm();
|
||||
const prefetchedRef = useRef(false);
|
||||
const pollingTimerRef = useRef(null);
|
||||
const statusRetryTimerRef = useRef(null);
|
||||
@ -51,9 +48,6 @@ const Install = () => {
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installStatus, setInstallStatus] = useState(null);
|
||||
const [installResult, setInstallResult] = useState(null);
|
||||
const [remoteInstalling, setRemoteInstalling] = useState(false);
|
||||
const [remoteResult, setRemoteResult] = useState(null);
|
||||
const [credentialsAcknowledged, setCredentialsAcknowledged] = useState(false);
|
||||
|
||||
const statusTag = useMemo(() => {
|
||||
if (!installStatus?.required) {
|
||||
@ -193,27 +187,6 @@ const Install = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoteInstall = async () => {
|
||||
try {
|
||||
const values = await remoteForm.validateFields();
|
||||
setRemoteInstalling(true);
|
||||
const result = await bootstrapRemoteInstallation({
|
||||
endpoint: values.endpoint,
|
||||
apiKey: values.apiKey,
|
||||
});
|
||||
remoteForm.setFieldValue('apiKey', '');
|
||||
setRemoteResult(result);
|
||||
message.success(result.message || '中央数据库和 Redis 配置完成');
|
||||
startPollingForRestart();
|
||||
} catch (error) {
|
||||
if (!error?.errorFields) {
|
||||
message.error(error.response?.data?.error || '获取远程系统数据库配置失败');
|
||||
}
|
||||
} finally {
|
||||
setRemoteInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (bootstrapping) {
|
||||
return (
|
||||
<SycmStandalonePage centered>
|
||||
@ -266,98 +239,6 @@ const Install = () => {
|
||||
|
||||
return (
|
||||
<SycmStandalonePage>
|
||||
<Modal
|
||||
open
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
title="连接中央运行环境"
|
||||
width={560}
|
||||
footer={remoteResult && !credentialsAcknowledged
|
||||
? [
|
||||
<Button key="saved" type="primary" onClick={() => setCredentialsAcknowledged(true)}>
|
||||
我已保存账号和密码
|
||||
</Button>,
|
||||
]
|
||||
: (remoteResult
|
||||
? null
|
||||
: [
|
||||
<Button
|
||||
key="enroll"
|
||||
type="primary"
|
||||
loading={remoteInstalling}
|
||||
onClick={handleRemoteInstall}
|
||||
>
|
||||
保存并获取配置
|
||||
</Button>,
|
||||
])}
|
||||
>
|
||||
{remoteResult ? (
|
||||
credentialsAcknowledged ? (
|
||||
<Result
|
||||
status="success"
|
||||
title="凭据已确认保存"
|
||||
subTitle={remoteResult.restartScheduled
|
||||
? '正在建立加密隧道并重启本地服务,完成后将自动进入登录页。'
|
||||
: '请使用 npm run remote:dev 重新启动,随后用刚才保存的管理员账号登录。'}
|
||||
extra={<Spin />}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="管理员账号和密码只显示这一次"
|
||||
description="请立即复制并保存在密码管理器中。关闭或刷新页面后无法再次查看密码。"
|
||||
/>
|
||||
<div>
|
||||
<Text type="secondary">管理员账号</Text>
|
||||
<div><Text code copyable>{remoteResult.adminUsername}</Text></div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">管理员密码</Text>
|
||||
<div><Text code copyable>{remoteResult.adminPassword}</Text></div>
|
||||
</div>
|
||||
<Text type="secondary">设备编号:{remoteResult.installationId}</Text>
|
||||
</Space>
|
||||
)
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="本机无需安装数据库或 Redis"
|
||||
description="保存后会通过 HTTPS 注册本机,自动配置中央系统数据库、公网 TLS Redis 和第三方数据库连接。登录后可直接使用,无需再进入配置向导。"
|
||||
/>
|
||||
<Form
|
||||
form={remoteForm}
|
||||
layout="vertical"
|
||||
initialValues={{ endpoint: 'https://ck.yydd.ai:8443/v1/enroll' }}
|
||||
>
|
||||
<Form.Item
|
||||
label="数据库配置请求地址"
|
||||
name="endpoint"
|
||||
rules={[
|
||||
{ required: true, message: '请输入配置请求地址' },
|
||||
{ type: 'url', message: '请输入有效的 HTTPS 地址' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="https://ck.yydd.ai:8443/v1/enroll" autoComplete="url" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="API Key"
|
||||
name="apiKey"
|
||||
rules={[{ required: true, message: '请输入 API Key' }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员发放的部署 Key"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
<SycmStandaloneFrame maxWidth={1120}>
|
||||
<SycmFlowHeader
|
||||
title="首次安装"
|
||||
|
||||
492
frontend/src/pages/MarketingEvents/index.jsx
Normal file
492
frontend/src/pages/MarketingEvents/index.jsx
Normal file
@ -0,0 +1,492 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Segmented,
|
||||
Select,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { SycmBusinessPanel, SycmDashboardMetric, SycmPage } from '../../components/Common';
|
||||
import {
|
||||
getMarketingEventsList,
|
||||
getMarketingEventsLatestDate,
|
||||
getMarketingEventsSummary,
|
||||
} from '../../api/marketingEvents';
|
||||
import './style.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '-';
|
||||
const num = Number(ts);
|
||||
if (!Number.isFinite(num)) return '-';
|
||||
const date = new Date(num > 1e12 ? num : num * 1000);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function formatPrice(value) {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '-';
|
||||
return `¥${num.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '-';
|
||||
return num.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
const STATUS_MAP = {
|
||||
501: { label: '报名成功', color: 'success' },
|
||||
502: { label: '审核中', color: 'processing' },
|
||||
503: { label: '已拒绝', color: 'error' },
|
||||
504: { label: '已取消', color: 'default' },
|
||||
505: { label: '已结束', color: 'warning' },
|
||||
};
|
||||
|
||||
function getStatusInfo(status) {
|
||||
if (STATUS_MAP[status]) return STATUS_MAP[status];
|
||||
return { label: `状态 ${status}`, color: 'default' };
|
||||
}
|
||||
|
||||
const MarketingEvents = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusCounts, setStatusCounts] = useState({ all: 0 });
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
|
||||
const [sorter, setSorter] = useState({ field: 'activity_goods_start_time', order: 'descend' });
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [groupByGoods, setGroupByGoods] = useState(true);
|
||||
const [queryFilters, setQueryFilters] = useState({});
|
||||
const [latestDate, setLatestDate] = useState('');
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
|
||||
const fetchLatestDate = useCallback(async () => {
|
||||
try {
|
||||
const res = await getMarketingEventsLatestDate();
|
||||
if (res?.data?.date) {
|
||||
setLatestDate(res.data.date);
|
||||
return res.data.date;
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
return '';
|
||||
}, []);
|
||||
|
||||
const fetchSummary = useCallback(async (date) => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const res = await getMarketingEventsSummary({ date });
|
||||
if (res?.data) {
|
||||
setSummary(res.data);
|
||||
}
|
||||
} catch {
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLatestDate().then((date) => {
|
||||
if (date) fetchSummary(date);
|
||||
});
|
||||
}, [fetchLatestDate, fetchSummary]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: sorter.field,
|
||||
sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||
...queryFilters,
|
||||
};
|
||||
if (statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
const result = await getMarketingEventsList(params);
|
||||
const data = result?.data;
|
||||
if (data) {
|
||||
setDataSource(data.list || []);
|
||||
setTotal(data.total || 0);
|
||||
if (data.statusCounts) {
|
||||
setStatusCounts(data.statusCounts);
|
||||
}
|
||||
if (data.filterDate) {
|
||||
setLatestDate(data.filterDate);
|
||||
fetchSummary(data.filterDate);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setDataSource([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination, sorter, statusFilter, queryFilters, fetchSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleTableChange = (pag, _filters, sort) => {
|
||||
setPagination({ current: pag.current, pageSize: pag.pageSize });
|
||||
if (sort.field) {
|
||||
setSorter({ field: sort.field, order: sort.order || 'descend' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
setQueryFilters({});
|
||||
setStatusFilter('all');
|
||||
setPagination({ current: 1, pageSize: 20 });
|
||||
setSorter({ field: 'activity_goods_start_time', order: 'descend' });
|
||||
};
|
||||
|
||||
const handleFinish = (values) => {
|
||||
const filters = {};
|
||||
if (values.shopName) filters.shopName = values.shopName;
|
||||
if (values.activityType) filters.activityType = values.activityType;
|
||||
if (values.activityName) filters.activityName = values.activityName;
|
||||
if (values.activityId) filters.activityId = values.activityId;
|
||||
if (values.goodsName) filters.goodsName = values.goodsName;
|
||||
if (values.goodsId) filters.goodsId = values.goodsId;
|
||||
if (values.dateRange?.length === 2) {
|
||||
filters.dateStart = values.dateRange[0].format('YYYY-MM-DD');
|
||||
filters.dateEnd = values.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
setQueryFilters(filters);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
};
|
||||
|
||||
const statusOptions = useMemo(() => [
|
||||
{ label: `全部 ${statusCounts.all || 0}`, value: 'all' },
|
||||
{ label: `报名成功 ${statusCounts[501] || 0}`, value: 501 },
|
||||
{ label: `审核中 ${statusCounts[502] || 0}`, value: 502 },
|
||||
{ label: `已拒绝 ${statusCounts[503] || 0}`, value: 503 },
|
||||
{ label: `已取消 ${statusCounts[504] || 0}`, value: 504 },
|
||||
{ label: `已结束 ${statusCounts[505] || 0}`, value: 505 },
|
||||
], [statusCounts]);
|
||||
|
||||
// 按商品聚合:同一商品多个活动时,最低价活动作为主行优先展示,其余折叠进展开行
|
||||
const displayData = useMemo(() => {
|
||||
const priceOf = (row) => {
|
||||
const num = Number(row?.minPrice);
|
||||
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
if (!groupByGoods) {
|
||||
return dataSource.map((row) => ({ ...row, activityCount: 1, childActivities: null }));
|
||||
}
|
||||
const groups = new Map();
|
||||
dataSource.forEach((row) => {
|
||||
const key = String(row.goodsId || row.id);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(row);
|
||||
});
|
||||
const list = [];
|
||||
groups.forEach((rows) => {
|
||||
const sorted = [...rows].sort((left, right) => priceOf(left) - priceOf(right));
|
||||
const [primary, ...rest] = sorted;
|
||||
list.push({
|
||||
...primary,
|
||||
activityCount: sorted.length,
|
||||
childActivities: rest.length ? rest : null,
|
||||
});
|
||||
});
|
||||
return list;
|
||||
}, [dataSource, groupByGoods]);
|
||||
|
||||
const summaryMetrics = useMemo(() => {
|
||||
if (!summary) return [];
|
||||
return [
|
||||
{ key: 'totalRecords', label: '活动记录数', value: formatNumber(summary.totalRecords) },
|
||||
{ key: 'successCount', label: '报名成功', value: formatNumber(summary.successCount) },
|
||||
{ key: 'successRate', label: '报名成功率', value: `${summary.successRate}%` },
|
||||
{ key: 'goodsCount', label: '参与商品数', value: formatNumber(summary.goodsCount) },
|
||||
{ key: 'shopCount', label: '参与店铺数', value: formatNumber(summary.shopCount) },
|
||||
{ key: 'activityIdCount', label: '活动数', value: formatNumber(summary.activityIdCount) },
|
||||
{ key: 'activityTypeCount', label: '活动类型', value: formatNumber(summary.activityTypeCount) },
|
||||
{ key: 'priceRange', label: '价格区间', value: summary.globalMinPrice !== '-' ? `${formatPrice(summary.globalMinPrice)} ~ ${formatPrice(summary.globalMaxPrice)}` : '-' },
|
||||
];
|
||||
}, [summary]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '商品图片',
|
||||
dataIndex: 'thumbUrl',
|
||||
key: 'thumbUrl',
|
||||
width: 80,
|
||||
fixed: 'left',
|
||||
render: (value, record) => (
|
||||
value ? (
|
||||
<img
|
||||
className="marketing-events-thumb"
|
||||
src={value}
|
||||
alt={record.goodsName || '商品图片'}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="marketing-events-thumb is-empty">暂无</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'goodsName',
|
||||
key: 'goodsName',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value, record) => (
|
||||
<div className="marketing-events-goods-cell">
|
||||
{value ? <span className="marketing-events-goods-name" title={value}>{value}</span> : '-'}
|
||||
<span className="marketing-events-goods-id">ID: {record.goodsId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '活动名称',
|
||||
dataIndex: 'activityName',
|
||||
key: 'activityName',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value, record) => (
|
||||
<div className="marketing-events-goods-cell">
|
||||
{value ? <span title={value}>{value}</span> : '-'}
|
||||
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||||
{record.activityCount > 1 ? (
|
||||
<Tag color="blue" style={{ marginRight: 0 }}>共{record.activityCount}个活动 · 已按最低价优先</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '活动类型',
|
||||
dataIndex: 'activityType',
|
||||
key: 'activityType',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '店铺',
|
||||
dataIndex: 'shopName',
|
||||
key: 'shopName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '报名时间',
|
||||
dataIndex: 'enrollTime',
|
||||
key: 'enrollTime',
|
||||
width: 160,
|
||||
sorter: true,
|
||||
render: (value) => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '活动开始',
|
||||
dataIndex: 'activityStartTime',
|
||||
key: 'activityStartTime',
|
||||
width: 160,
|
||||
sorter: true,
|
||||
render: (value) => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '活动结束',
|
||||
dataIndex: 'activityEndTime',
|
||||
key: 'activityEndTime',
|
||||
width: 160,
|
||||
sorter: true,
|
||||
render: (value) => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '最低价',
|
||||
dataIndex: 'minPrice',
|
||||
key: 'minPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => formatPrice(value),
|
||||
},
|
||||
{
|
||||
title: '最高价',
|
||||
dataIndex: 'maxPrice',
|
||||
key: 'maxPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => formatPrice(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (value) => {
|
||||
const info = getStatusInfo(value);
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
},
|
||||
},
|
||||
], []);
|
||||
|
||||
const childActivityColumns = [
|
||||
{ title: '活动名称', dataIndex: 'activityName', width: 200, ellipsis: true, render: (value, record) => (
|
||||
<div className="marketing-events-goods-cell">
|
||||
{value ? <span title={value}>{value}</span> : '-'}
|
||||
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||||
</div>
|
||||
) },
|
||||
{ title: '活动类型', dataIndex: 'activityType', width: 140, ellipsis: true, render: (value) => value || '-' },
|
||||
{ title: '报名时间', dataIndex: 'enrollTime', width: 160, render: formatTimestamp },
|
||||
{ title: '活动开始', dataIndex: 'activityStartTime', width: 160, render: formatTimestamp },
|
||||
{ title: '活动结束', dataIndex: 'activityEndTime', width: 160, render: formatTimestamp },
|
||||
{ title: '最低价', dataIndex: 'minPrice', width: 100, align: 'right', render: formatPrice },
|
||||
{ title: '最高价', dataIndex: 'maxPrice', width: 100, align: 'right', render: formatPrice },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (value) => {
|
||||
const info = getStatusInfo(value);
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
} },
|
||||
];
|
||||
|
||||
return (
|
||||
<SycmPage className="marketing-events-page">
|
||||
<SycmBusinessPanel className="marketing-events-panel" aria-label="营销活动内容区">
|
||||
<div className="marketing-events-panel__heading">
|
||||
<h2>营销活动</h2>
|
||||
<span>查看拼多多平台活动报名记录</span>
|
||||
{latestDate && (
|
||||
<span className="marketing-events-data-date">最新数据日期:{latestDate}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Spin spinning={summaryLoading}>
|
||||
<div className="marketing-events-summary">
|
||||
{summaryMetrics.map((item) => (
|
||||
<SycmDashboardMetric
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
help={false}
|
||||
/>
|
||||
))}
|
||||
{!summary && !summaryLoading && (
|
||||
<span className="marketing-events-summary__empty">暂无汇总数据</span>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="marketing-events-filter"
|
||||
layout="horizontal"
|
||||
colon={false}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<Form.Item label="店铺名称" name="shopName">
|
||||
<Input allowClear placeholder="输入店铺名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动类型" name="activityType">
|
||||
<Input allowClear placeholder="输入活动类型" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动名称" name="activityName">
|
||||
<Input allowClear placeholder="输入活动名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动ID" name="activityId">
|
||||
<Input allowClear placeholder="输入活动ID" />
|
||||
</Form.Item>
|
||||
<Form.Item label="商品名称" name="goodsName">
|
||||
<Input allowClear placeholder="输入商品名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="商品ID" name="goodsId">
|
||||
<Input allowClear placeholder="输入商品ID" />
|
||||
</Form.Item>
|
||||
<Form.Item label="活动时间" name="dateRange">
|
||||
<RangePicker allowClear placeholder={['开始日期', '结束日期']} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div className="marketing-events-filter__actions">
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>查询</Button>
|
||||
<Button htmlType="button" icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="marketing-events-table-toolbar">
|
||||
<Segmented
|
||||
className="marketing-events-status-switch"
|
||||
value={statusFilter}
|
||||
options={statusOptions}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(value);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
}}
|
||||
/>
|
||||
<Segmented
|
||||
value={groupByGoods ? 'group' : 'flat'}
|
||||
options={[
|
||||
{ label: '按商品聚合(最低价优先)', value: 'group' },
|
||||
{ label: '按活动明细', value: 'flat' },
|
||||
]}
|
||||
onChange={(value) => setGroupByGoods(value === 'group')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
className="marketing-events-table"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={displayData}
|
||||
onChange={handleTableChange}
|
||||
expandable={groupByGoods ? {
|
||||
expandedRowRender: (record) => (
|
||||
<Table
|
||||
className="marketing-events-child-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={childActivityColumns}
|
||||
dataSource={record.childActivities || []}
|
||||
/>
|
||||
),
|
||||
rowExpandable: (record) => Boolean(record.childActivities?.length),
|
||||
} : undefined}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 1600 }}
|
||||
locale={{ emptyText: loading ? '加载中...' : '暂无活动记录' }}
|
||||
size="small"
|
||||
/>
|
||||
</Spin>
|
||||
</SycmBusinessPanel>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketingEvents;
|
||||
224
frontend/src/pages/MarketingEvents/style.css
Normal file
224
frontend/src/pages/MarketingEvents/style.css
Normal file
@ -0,0 +1,224 @@
|
||||
.marketing-events-panel {
|
||||
min-height: calc(100vh - 84px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.marketing-events-panel__heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding: var(--sycm-module-padding) var(--sycm-module-padding) 0;
|
||||
}
|
||||
|
||||
.marketing-events-panel__heading h2 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: var(--sycm-line-height-tight);
|
||||
}
|
||||
|
||||
.marketing-events-panel__heading span {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.marketing-events-data-date {
|
||||
margin-left: auto;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marketing-events-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
gap: var(--sycm-space-sm);
|
||||
margin-top: var(--sycm-space-sm);
|
||||
padding: 0 var(--sycm-module-padding);
|
||||
}
|
||||
|
||||
.marketing-events-summary .sycm-dashboard-metric {
|
||||
min-height: 88px;
|
||||
padding: var(--sycm-space-sm);
|
||||
border-radius: var(--sycm-radius-lg);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.marketing-events-summary .sycm-dashboard-metric__label {
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.marketing-events-summary .sycm-dashboard-metric__value {
|
||||
font-size: var(--sycm-font-size-metric);
|
||||
}
|
||||
|
||||
.marketing-events-summary__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: var(--sycm-space-md);
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.marketing-events-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: var(--sycm-space-sm) var(--sycm-space-xl);
|
||||
margin-top: var(--sycm-space-sm);
|
||||
padding: var(--sycm-module-padding);
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-label {
|
||||
padding: 0 var(--sycm-space-sm) 0 0;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-label > label {
|
||||
height: var(--sycm-control-height);
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
line-height: var(--sycm-control-height);
|
||||
}
|
||||
|
||||
.marketing-events-filter .ant-form-item-control,
|
||||
.marketing-events-filter .ant-form-item-control-input,
|
||||
.marketing-events-filter .ant-form-item-control-input-content {
|
||||
min-height: var(--sycm-control-height);
|
||||
}
|
||||
|
||||
.marketing-events-filter__actions {
|
||||
display: flex;
|
||||
min-height: var(--sycm-control-height);
|
||||
align-items: center;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding-left: 72px;
|
||||
}
|
||||
|
||||
.marketing-events-table-toolbar {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-md);
|
||||
padding: var(--sycm-space-sm) var(--sycm-module-padding);
|
||||
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.marketing-events-status-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.marketing-events-child-table {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.marketing-events-child-table .ant-table {
|
||||
background: var(--sycm-bg-page-soft, #fafafa);
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-wrapper,
|
||||
.marketing-events-table .ant-spin-nested-loading,
|
||||
.marketing-events-table .ant-spin-container {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-cell {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-thead > tr > th {
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-tbody > tr > td {
|
||||
color: var(--sycm-text-primary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.marketing-events-table .ant-table-placeholder .ant-table-cell {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.marketing-events-thumb {
|
||||
display: block;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.marketing-events-thumb.is-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--sycm-bg-subtle);
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.marketing-events-goods-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-xxs);
|
||||
line-height: var(--sycm-line-height-normal);
|
||||
}
|
||||
|
||||
.marketing-events-goods-name {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-strong);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marketing-events-goods-id {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.marketing-events-panel__heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-xxs);
|
||||
}
|
||||
|
||||
.marketing-events-filter {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sycm-space-sm);
|
||||
}
|
||||
|
||||
.marketing-events-filter__actions {
|
||||
padding-left: 72px;
|
||||
}
|
||||
}
|
||||
233
frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx
Normal file
233
frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx
Normal file
@ -0,0 +1,233 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Col, DatePicker, Descriptions, Drawer, Dropdown, Input, Row, Select,
|
||||
Space, Spin, Statistic, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getOpsBIncentiveBatches, getOpsBIncentiveBoard, getOpsBIncentiveDaily } from '../../api/planningUpload';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './opsBIncentiveBoard.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const FALLBACK_BATCH = { key: 'ops-b-20260715-20260721', batchNo: 1, scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 };
|
||||
const batchLabel = (item) => {
|
||||
const mmdd = (value) => dayjs(value).format('MM-DD');
|
||||
const cap = `${parseFloat((Number(item.feeCap) * 100).toFixed(1))}%`;
|
||||
return `第${item.batchNo}批(${mmdd(item.scopeStart)} 至 ${mmdd(item.scopeEnd)} · 拉取 ${mmdd(item.pullDate)} · 费比≤${cap})${item.finalized ? ' · 已定档' : ''}`;
|
||||
};
|
||||
|
||||
const number = (value, digits = 2) => value === null || value === undefined || value === ''
|
||||
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
const money = (value) => value === null || value === undefined ? '--' : `¥${number(value)}`;
|
||||
const percent = (value) => value === null || value === undefined ? '--' : `${(Number(value) * 100).toFixed(2)}%`;
|
||||
const dateText = (value) => value ? dayjs(value).format('YYYY-MM-DD') : '--';
|
||||
|
||||
const OpsBIncentiveBoardPanel = () => {
|
||||
const [batches, setBatches] = useState([]);
|
||||
const [batch, setBatch] = useState(FALLBACK_BATCH.key);
|
||||
const [metricEnd, setMetricEnd] = useState(dayjs(FALLBACK_BATCH.pullDate));
|
||||
const [filters, setFilters] = useState({ ider: '', shopName: '', spu: '', goodsId: '' });
|
||||
const [data, setData] = useState({ rows: [], ownerSummary: [], summary: {} });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [dailyRows, setDailyRows] = useState([]);
|
||||
const [dailyLoading, setDailyLoading] = useState(false);
|
||||
|
||||
const selectedBatch = useMemo(
|
||||
() => batches.find((item) => item.key === batch) || FALLBACK_BATCH,
|
||||
[batches, batch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getOpsBIncentiveBatches()
|
||||
.then((result) => {
|
||||
const list = result?.data?.data || result?.data || [];
|
||||
if (cancelled || !Array.isArray(list) || !list.length) return;
|
||||
setBatches(list);
|
||||
if (!list.some((item) => item.key === FALLBACK_BATCH.key)) {
|
||||
setBatch(list[0].key);
|
||||
setMetricEnd(dayjs(list[0].pullDate));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const query = useMemo(() => ({
|
||||
scopeStart: selectedBatch.scopeStart,
|
||||
scopeEnd: selectedBatch.scopeEnd,
|
||||
metricEnd: metricEnd?.format('YYYY-MM-DD') || selectedBatch.pullDate,
|
||||
...filters,
|
||||
}), [selectedBatch, metricEnd, filters]);
|
||||
|
||||
const changeBatch = (value) => {
|
||||
setBatch(value);
|
||||
const next = batches.find((item) => item.key === value);
|
||||
if (next) setMetricEnd(dayjs(next.pullDate));
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const result = await getOpsBIncentiveBoard(query);
|
||||
setData(result?.data || result || { rows: [], ownerSummary: [], summary: {} });
|
||||
} catch (requestError) {
|
||||
setError(requestError?.message || '加载运营中心B看板失败');
|
||||
} finally { setLoading(false); }
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openDetail = async (record) => {
|
||||
setSelected(record); setDailyRows([]); setDailyLoading(true);
|
||||
try {
|
||||
const result = await getOpsBIncentiveDaily({ ...query, goodsId: record.goodsId });
|
||||
setDailyRows(result?.data?.rows || result?.rows || []);
|
||||
} catch (requestError) {
|
||||
message.error(requestError?.message || '加载每日明细失败');
|
||||
} finally { setDailyLoading(false); }
|
||||
};
|
||||
|
||||
const iderOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.ider).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]);
|
||||
const shopOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.shopName).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]);
|
||||
// 分组:0=动销率与费比均达标,1=仅一项达标,2=均不达标;组内按动销率降序。
|
||||
const sortedRows = useMemo(() => [...(data.rows || [])].sort((a, b) => {
|
||||
const group = (row) => (row.bonusQualified ? 0 : (row.feeQualified || row.sellThroughQualified ? 1 : 2));
|
||||
const rate = (row) => (row.sellThroughRate30d === null || row.sellThroughRate30d === undefined ? -1 : row.sellThroughRate30d);
|
||||
return group(a) - group(b) || rate(b) - rate(a) || String(a.goodsId).localeCompare(String(b.goodsId));
|
||||
}), [data.rows]);
|
||||
const num = (value) => (value === null || value === undefined || value === '' ? -Infinity : Number(value));
|
||||
const qualifyFilters = [
|
||||
{ text: '达标', value: 'yes' },
|
||||
{ text: '未达标', value: 'no' },
|
||||
];
|
||||
const qualifyFilter = (field) => ({
|
||||
filters: qualifyFilters,
|
||||
onFilter: (value, row) => (value === 'yes' ? Boolean(row[field]) : !row[field]),
|
||||
});
|
||||
const columns = [
|
||||
{ title: 'ID负责人', dataIndex: 'ider', fixed: 'left', width: 100 },
|
||||
{ title: '商品ID', dataIndex: 'goodsId', fixed: 'left', width: 130, render: (value, row) => <Button type="link" className="ops-b-id-link" onClick={() => openDetail(row)}>{value}</Button> },
|
||||
{ title: '商品名称', dataIndex: 'goodsName', width: 220, ellipsis: true },
|
||||
{ title: '店铺名称', dataIndex: 'shopName', width: 150, ellipsis: true },
|
||||
{ title: 'SPU', dataIndex: 'spu', width: 120 },
|
||||
{ title: '链接类型', dataIndex: 'linkType', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (v) => <Tag color={v === '在售' ? 'green' : 'default'}>{v}</Tag> },
|
||||
{ title: 'SKU数', dataIndex: 'skuCount', width: 80, align: 'right', sorter: (a, b) => num(a.skuCount) - num(b.skuCount) },
|
||||
{ title: '上架时间', dataIndex: 'firstCreatedAt', width: 120, render: dateText, exportValue: dateText },
|
||||
{ title: '上架天数', dataIndex: 'listingDays', width: 90, align: 'right', sorter: (a, b) => num(a.listingDays) - num(b.listingDays), render: (v) => number(v, 0) },
|
||||
{ title: '近30天ID动销率', dataIndex: 'sellThroughRate30d', width: 130, align: 'right', sorter: (a, b) => num(a.sellThroughRate30d) - num(b.sellThroughRate30d), render: percent, exportValue: percent },
|
||||
{ title: '动销率是否达标', dataIndex: 'sellThroughQualified', width: 120, ...qualifyFilter('sellThroughQualified'), render: (v) => <Tag color={v ? 'green' : 'red'}>{v ? '达标' : '未达标'}</Tag>, exportValue: (v) => (v ? '达标' : '未达标') },
|
||||
{ title: '近30天费比', dataIndex: 'feeRatio30d', width: 110, align: 'right', sorter: (a, b) => num(a.feeRatio30d) - num(b.feeRatio30d), render: percent, exportValue: percent },
|
||||
{ title: '费比是否达标', dataIndex: 'feeQualified', width: 105, ...qualifyFilter('feeQualified'), render: (v) => <Tag color={v ? 'green' : 'red'}>{v ? '达标' : '未达标'}</Tag>, exportValue: (v) => (v ? '达标' : '未达标') },
|
||||
{ title: '近30天真实业绩', dataIndex: 'performance30d', width: 135, align: 'right', sorter: (a, b) => num(a.performance30d) - num(b.performance30d), render: money },
|
||||
{ title: '近30天真实费用', dataIndex: 'fee30d', width: 135, align: 'right', sorter: (a, b) => num(a.fee30d) - num(b.fee30d), render: money },
|
||||
{ title: '近30天发货件数', dataIndex: 'goodsCount30d', width: 125, align: 'right', sorter: (a, b) => num(a.goodsCount30d) - num(b.goodsCount30d), render: (v) => number(v, 0) },
|
||||
{ title: '近30天发货天数', dataIndex: 'shippedDays30d', width: 125, align: 'right', sorter: (a, b) => num(a.shippedDays30d) - num(b.shippedDays30d), render: (v) => number(v, 0) },
|
||||
{ title: '应发奖金', dataIndex: 'bonus', width: 100, align: 'right', sorter: (a, b) => num(a.bonus) - num(b.bonus), render: money },
|
||||
];
|
||||
|
||||
const ownerSummaryColumns = [
|
||||
{ title: 'ID负责人', dataIndex: 'ider' },
|
||||
{ title: '商品ID数', dataIndex: 'goodsIdCount' },
|
||||
{ title: '近30天发货件数', dataIndex: 'goodsCount30d', render: (v) => number(v, 0) },
|
||||
{ title: '费比达标', dataIndex: 'feeQualifiedCount' },
|
||||
{ title: '奖金达标', dataIndex: 'bonusQualifiedCount' },
|
||||
{ title: '应发奖金', dataIndex: 'bonus', render: money },
|
||||
];
|
||||
|
||||
const exportFileName = (label) => (
|
||||
`激励看板_${label}_${selectedBatch.scopeStart}_${selectedBatch.scopeEnd}_统计至${metricEnd?.format('YYYYMMDD') || '最新'}`
|
||||
);
|
||||
|
||||
const handleExport = async (scope, format) => {
|
||||
const isSummary = scope === 'summary';
|
||||
const exportColumns = isSummary ? ownerSummaryColumns : columns;
|
||||
const exportRows = isSummary ? (data.ownerSummary || []) : sortedRows;
|
||||
if (!exportRows.length) {
|
||||
message.warning(isSummary ? '负责人汇总暂无数据可导出' : 'ID核心明细暂无数据可导出');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await exportTableFile(format, exportFileName(isSummary ? '负责人汇总' : 'ID核心明细'), exportColumns, exportRows, isSummary ? '负责人汇总' : 'ID核心明细');
|
||||
message.success(`已导出 ${exportRows.length} 条数据(${format.toUpperCase()})`);
|
||||
} catch (exportError) {
|
||||
message.error(exportError?.message || '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const exportDropdown = (scope) => (
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'xlsx', label: '导出 XLSX' },
|
||||
{ key: 'csv', label: '导出 CSV' },
|
||||
],
|
||||
onClick: ({ key }) => handleExport(scope, key),
|
||||
}}>
|
||||
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
const summary = data.summary || {};
|
||||
return (
|
||||
<>
|
||||
<Card className="ops-b-filter-card" bordered={false}>
|
||||
<Space wrap size={[12, 12]}>
|
||||
<Select
|
||||
value={batch}
|
||||
options={(batches.length ? batches : [FALLBACK_BATCH]).map((item) => ({ value: item.key, label: batchLabel(item) }))}
|
||||
onChange={changeBatch}
|
||||
style={{ width: 360 }}
|
||||
/>
|
||||
<DatePicker value={metricEnd} allowClear={false} onChange={setMetricEnd} placeholder="统计截止日" />
|
||||
<Select allowClear showSearch placeholder="ID负责人" options={iderOptions} value={filters.ider || undefined} onChange={(value) => setFilters((old) => ({ ...old, ider: value || '' }))} style={{ width: 150 }} />
|
||||
<Select allowClear showSearch placeholder="店铺名称" options={shopOptions} value={filters.shopName || undefined} onChange={(value) => setFilters((old) => ({ ...old, shopName: value || '' }))} style={{ width: 180 }} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="SPU" value={filters.spu} onChange={(e) => setFilters((old) => ({ ...old, spu: e.target.value }))} style={{ width: 150 }} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="商品ID" value={filters.goodsId} onChange={(e) => setFilters((old) => ({ ...old, goodsId: e.target.value }))} style={{ width: 170 }} />
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||
<Row gutter={[12, 12]} className="ops-b-summary-row">
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-blue"><Statistic title="商品ID数" value={summary.goodsIdCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-green"><Statistic title="费比达标ID" value={summary.feeQualifiedCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-orange"><Statistic title="动销率 > 30%" value={summary.sellThroughQualifiedCount || 0} /></Card></Col>
|
||||
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-gold"><Statistic title="应发奖金" value={summary.bonus || 0} prefix="¥" precision={2} /></Card></Col>
|
||||
</Row>
|
||||
<Card bordered={false} className="ops-b-panel" title="负责人汇总" extra={exportDropdown('summary')}>
|
||||
<Table rowKey="ider" size="small" pagination={false} dataSource={data.ownerSummary || []} columns={ownerSummaryColumns} />
|
||||
</Card>
|
||||
<Card bordered={false} className="ops-b-panel" title={`ID核心明细(${(data.rows || []).length} 条)`} extra={exportDropdown('detail')}>
|
||||
<Spin spinning={loading} tip="正在加载运营中心B数据…"><Table rowKey="goodsId" size="small" columns={columns} dataSource={sortedRows} pagination={false} scroll={{ x: 2400, y: 'min(520px, calc(100vh - 460px))' }} /></Spin>
|
||||
</Card>
|
||||
<Drawer className="ops-b-detail-drawer" title={selected ? `${selected.goodsId} · 每日发货明细` : '每日发货明细'} width={760} open={Boolean(selected)} onClose={() => setSelected(null)}>
|
||||
{selected ? <><Descriptions size="small" bordered column={2} items={[{ key: 'name', label: '商品名称', children: selected.goodsName || '--' }, { key: 'owner', label: '负责人', children: selected.ider || '--' }, { key: 'fee', label: '近30天费比', children: percent(selected.feeRatio30d) }, { key: 'bonus', label: '奖金状态', children: selected.bonusQualified ? <Tag color="gold">达标 ¥{AWARD}</Tag> : <Tag>未达标</Tag> }]} /><Table className="ops-b-daily-table" loading={dailyLoading} rowKey="statDate" size="small" pagination={false} dataSource={dailyRows} columns={[{ title: '日期', dataIndex: 'statDate', render: dateText }, { title: '发货件数', dataIndex: 'goodsCount', align: 'right', render: (v) => number(v, 0) }, { title: '是否动销', dataIndex: 'isActive' }, { title: '真实业绩', dataIndex: 'realPerformance', align: 'right', render: money }, { title: '真实费用', dataIndex: 'realFee', align: 'right', render: money }]} /></> : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OpsBIncentiveBoard = () => (
|
||||
<SycmPage className="ops-b-incentive-page">
|
||||
<header className="ops-b-header">
|
||||
<div className="ops-b-header__heading">
|
||||
<Title level={3}>激励看板</Title>
|
||||
<Text type="secondary">按中心切换查看商品激励奖金、费比与动销达成情况</Text>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
className="ops-b-tabs"
|
||||
defaultActiveKey="ops-b"
|
||||
items={[
|
||||
{ key: 'ops-b', label: '运营中心B', children: <OpsBIncentiveBoardPanel /> },
|
||||
]}
|
||||
/>
|
||||
</SycmPage>
|
||||
);
|
||||
|
||||
const AWARD = 400;
|
||||
export default OpsBIncentiveBoard;
|
||||
160
frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css
Normal file
160
frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css
Normal file
@ -0,0 +1,160 @@
|
||||
.ops-b-incentive-page {
|
||||
container-name: ops-b-incentive;
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
min-height: calc(100% - 46px);
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
background: var(--sycm-bg-page-soft);
|
||||
}
|
||||
|
||||
.ops-b-header {
|
||||
display: flex;
|
||||
min-height: 84px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sycm-space-lg);
|
||||
margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0;
|
||||
padding: 14px calc(var(--sycm-page-padding-x) + 2px);
|
||||
border-bottom: 1px solid var(--sycm-border-base);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
|
||||
.ops-b-header__heading { min-width: 0; }
|
||||
.ops-b-header h3 {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-page);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
line-height: var(--sycm-line-height-tight);
|
||||
}
|
||||
.ops-b-header__heading .ant-typography {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.ops-b-tabs .ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 calc(var(--sycm-page-padding-x) + 2px);
|
||||
border-bottom: 1px solid var(--sycm-border-base);
|
||||
background: var(--sycm-bg-container);
|
||||
}
|
||||
.ops-b-tabs .ant-tabs-tab {
|
||||
margin: 0;
|
||||
padding: 12px 4px;
|
||||
font-size: var(--sycm-font-size-title);
|
||||
}
|
||||
.ops-b-tabs .ant-tabs-tab + .ant-tabs-tab { margin-left: 32px; }
|
||||
.ops-b-tabs .ant-tabs-content-holder,
|
||||
.ops-b-tabs .ant-tabs-content,
|
||||
.ops-b-tabs .ant-tabs-tabpane { min-height: 100%; }
|
||||
.ops-b-tabs .ant-tabs-tabpane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-page-section-gap);
|
||||
padding-top: var(--sycm-page-section-gap);
|
||||
}
|
||||
|
||||
.ops-b-filter-card,
|
||||
.ops-b-panel,
|
||||
.ops-b-summary-card {
|
||||
border: 1px solid var(--sycm-border-card) !important;
|
||||
border-radius: var(--sycm-radius-lg) !important;
|
||||
box-shadow: var(--sycm-shadow-card) !important;
|
||||
}
|
||||
.ops-b-filter-card { margin-bottom: 0; }
|
||||
.ops-b-filter-card .ant-card-body { padding: 14px 16px; }
|
||||
.ops-b-filter-card .ant-space { width: 100%; }
|
||||
.ops-b-filter-card .ant-space-item { max-width: 100%; }
|
||||
.ops-b-filter-card .ant-select,
|
||||
.ops-b-filter-card .ant-picker,
|
||||
.ops-b-filter-card .ant-input-affix-wrapper { max-width: 100%; }
|
||||
|
||||
.ops-b-summary-row { margin-top: -4px; }
|
||||
.ops-b-summary-row .ant-col { display: flex; }
|
||||
.ops-b-summary-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 98px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ops-b-summary-card::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--sycm-color-primary);
|
||||
content: '';
|
||||
}
|
||||
.ops-b-summary-card.is-green::before { background: var(--sycm-color-success); }
|
||||
.ops-b-summary-card.is-orange::before { background: var(--sycm-color-orange); }
|
||||
.ops-b-summary-card.is-gold::before { background: var(--sycm-color-warning); }
|
||||
.ops-b-summary-card .ant-card-body {
|
||||
display: flex;
|
||||
min-height: 98px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.ops-b-summary-card .ant-statistic { width: 100%; }
|
||||
.ops-b-summary-card .ant-statistic-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
.ops-b-summary-card .ant-statistic-content {
|
||||
color: var(--sycm-text-strong);
|
||||
font-family: var(--sycm-font-family-number);
|
||||
font-size: var(--sycm-font-size-metric);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ops-b-panel { margin-bottom: 0; overflow: hidden; }
|
||||
.ops-b-panel .ant-card-head {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom-color: var(--sycm-border-light);
|
||||
}
|
||||
.ops-b-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
|
||||
.ops-b-panel .ant-card-body { padding: 0 16px 16px; }
|
||||
.ops-b-panel .ant-table { font-size: var(--sycm-font-size-caption); }
|
||||
.ops-b-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
|
||||
.ops-b-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
|
||||
.ops-b-id-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-family: var(--sycm-font-family-number);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
.ops-b-daily-table { margin-top: var(--sycm-space-md); }
|
||||
|
||||
.ops-b-detail-drawer .ant-drawer-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom-color: var(--sycm-border-light);
|
||||
}
|
||||
.ops-b-detail-drawer .ant-drawer-title {
|
||||
color: var(--sycm-text-strong);
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
.ops-b-detail-drawer .ant-drawer-body { padding: 20px; }
|
||||
.ops-b-detail-drawer .ant-descriptions { margin-bottom: var(--sycm-space-md); }
|
||||
|
||||
@container ops-b-incentive (max-width: 900px) {
|
||||
.ops-b-filter-card .ant-space-item { flex: 1 1 180px; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ops-b-header {
|
||||
min-height: 72px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-sm);
|
||||
padding-block: 12px;
|
||||
}
|
||||
.ops-b-filter-card .ant-select,
|
||||
.ops-b-filter-card .ant-picker,
|
||||
.ops-b-filter-card .ant-input-affix-wrapper { width: 100%; }
|
||||
.ops-b-panel .ant-card-body { padding-inline: 10px; }
|
||||
.ops-b-detail-drawer .ant-drawer-body { padding: 14px; }
|
||||
}
|
||||
75
frontend/src/pages/PricingCalculator/DefaultsConfigModal.jsx
Normal file
75
frontend/src/pages/PricingCalculator/DefaultsConfigModal.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { DiscountInput, MoneyInput, PercentInput } from './inputs';
|
||||
import { SYSTEM_DEFAULTS } from './constants';
|
||||
|
||||
const FIELD_DEFS = [
|
||||
{ key: 'coupon', label: '优惠券金额', Input: MoneyInput },
|
||||
{ key: 'activityDiscount', label: '活动折扣', Input: DiscountInput },
|
||||
{ key: 'installFee', label: '安装费', Input: MoneyInput },
|
||||
{ key: 'targetMargin', label: '营业利润率', Input: PercentInput },
|
||||
];
|
||||
|
||||
const formatDefaultTip = (key, value) => {
|
||||
if (key === 'targetMargin') return `系统默认 ${Number(value * 100).toFixed(0)}%`;
|
||||
if (key === 'activityDiscount') return `系统默认 ${value}(即 ${Number(value * 10).toFixed(0)} 折)`;
|
||||
return `系统默认 ${value}`;
|
||||
};
|
||||
|
||||
// 表单主体:每次打开弹窗时重新挂载,初始值取 个性化配置 > 系统默认
|
||||
const DefaultsForm = ({ defaults, systemDefaults, saving, onSave }) => {
|
||||
const [form, setForm] = useState(() => ({ ...systemDefaults, ...(defaults || {}) }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="pricing-calc-defaults__tip">
|
||||
配置保存到你的账号,仅自己生效。保存后页面中仍使用旧默认值的行会自动更新为新值,手动修改过的行不受影响;之后的新的测算也按此填充。
|
||||
</p>
|
||||
{FIELD_DEFS.map(({ key, label, Input }) => (
|
||||
<div key={key} className="pricing-calc-defaults__row">
|
||||
<span className="pricing-calc-defaults__label">{label}</span>
|
||||
{React.createElement(Input, {
|
||||
style: { width: 160 },
|
||||
value: form[key],
|
||||
onChange: (next) => setForm((prev) => ({ ...prev, [key]: next })),
|
||||
})}
|
||||
<span className="pricing-calc-defaults__hint">
|
||||
{formatDefaultTip(key, systemDefaults[key])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pricing-calc-defaults__footer">
|
||||
<Button onClick={() => setForm({ ...systemDefaults })}>恢复系统默认</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => onSave(form)}>保存配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 个性化默认值配置弹窗:
|
||||
* - 每个运营维护自己的默认值,保存到本人账号(服务端按用户隔离)
|
||||
* - 用于"新测算/新条码行"的默认值填充;已生成的行与历史快照不受影响
|
||||
*/
|
||||
const DefaultsConfigModal = ({
|
||||
open,
|
||||
onClose,
|
||||
defaults,
|
||||
systemDefaults = SYSTEM_DEFAULTS,
|
||||
saving,
|
||||
onSave,
|
||||
title = '默认值配置',
|
||||
}) => (
|
||||
<Modal title={title} width={440} open={open} onCancel={onClose} footer={null}>
|
||||
{open ? (
|
||||
<DefaultsForm
|
||||
defaults={defaults}
|
||||
systemDefaults={systemDefaults}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default DefaultsConfigModal;
|
||||
909
frontend/src/pages/PricingCalculator/DropshipCalculator.jsx
Normal file
909
frontend/src/pages/PricingCalculator/DropshipCalculator.jsx
Normal file
@ -0,0 +1,909 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popover,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
ControlOutlined,
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
HistoryOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { SycmDashboardMetric } from '../../components/Common';
|
||||
import { getPlanningAlibaba1688ProductDetail } from '../../api/planningUpload';
|
||||
import {
|
||||
exportPricingTable,
|
||||
fetchPricingHistoryDetail,
|
||||
fetchPricingPrefs,
|
||||
savePricingHistory,
|
||||
savePricingPrefs,
|
||||
} from '../../api/pricingCalculator';
|
||||
import { computeRow, formatMoney, formatPercent } from './calc';
|
||||
import { DiscountInput, MoneyInput, PercentInput } from './inputs';
|
||||
import DefaultsConfigModal from './DefaultsConfigModal';
|
||||
import FieldConfigDrawer from './FieldConfigDrawer';
|
||||
import HistoryDrawer from './HistoryDrawer';
|
||||
import {
|
||||
applyDropshipDefaultsToRows,
|
||||
createDropshipRows,
|
||||
DROPSHIP_FIXED_RATES,
|
||||
DROPSHIP_SYSTEM_DEFAULTS,
|
||||
getDropshipDimensions,
|
||||
getDropshipSpecValue,
|
||||
mergeDropshipSelectionPricingRows,
|
||||
parseAlibaba1688ProductId,
|
||||
} from './dropship';
|
||||
import {
|
||||
createDefaultDropshipFieldConfig,
|
||||
DROPSHIP_FIELD_LABELS,
|
||||
loadDropshipFieldConfig,
|
||||
saveDropshipFieldConfig,
|
||||
} from './dropshipFieldConfig';
|
||||
import { PERCENT_FIELD_KEYS } from './fieldConfig';
|
||||
import './style.css';
|
||||
|
||||
const DEFAULT_FILTERS = { available: undefined, keyword: '', specs: {} };
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
{ className: 'is-input', label: '可填写(到手价 / 安装费 / 营业利润率)' },
|
||||
{ className: 'is-auto', label: '1688 自动获取 / 固定比例' },
|
||||
{ className: 'is-formula', label: '公式' },
|
||||
{ className: 'is-pdd', label: '拼多多活动(优惠券 / 折扣可填)' },
|
||||
{ className: 'is-danger', label: '营业利润额为负(亏本)' },
|
||||
];
|
||||
|
||||
const ProductThumb = ({ url, size = 36 }) => {
|
||||
const [failedUrl, setFailedUrl] = useState('');
|
||||
const normalizedUrl = String(url || '').trim().replace(/^\/\//, 'https://');
|
||||
|
||||
if (!normalizedUrl || failedUrl === normalizedUrl) {
|
||||
return (
|
||||
<span className="pricing-calc-thumb is-empty" style={{ width: size, height: size }}>
|
||||
无图
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
className="pricing-calc-thumb"
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: 'cover' }}
|
||||
src={normalizedUrl}
|
||||
alt="商品图片"
|
||||
referrerPolicy="no-referrer"
|
||||
preview={{
|
||||
src: normalizedUrl,
|
||||
// 1688 图片有防盗链,预览大图也必须以 no-referrer 请求
|
||||
imageRender: () => (
|
||||
<img
|
||||
referrerPolicy="no-referrer"
|
||||
src={normalizedUrl}
|
||||
alt="商品图片预览"
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
onError={() => setFailedUrl(normalizedUrl)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AutoHeaderTitle = ({ children, tip }) => (
|
||||
<span className="pricing-calc-th-title">
|
||||
{children}
|
||||
{tip ? (
|
||||
<Tooltip title={tip}>
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
const numericSorter = (field) => (leftRow, rightRow) => {
|
||||
const left = Number.isFinite(leftRow[field]) ? leftRow[field] : Number.NEGATIVE_INFINITY;
|
||||
const right = Number.isFinite(rightRow[field]) ? rightRow[field] : Number.NEGATIVE_INFINITY;
|
||||
return left - right;
|
||||
};
|
||||
|
||||
const getFriendlyError = (error, fallback) => (
|
||||
error?.response?.data?.error || error?.message || fallback
|
||||
);
|
||||
|
||||
const normalizeRestoredRows = (rows, defaults) => (Array.isArray(rows) ? rows : []).map((row, index) => ({
|
||||
...row,
|
||||
id: row.id || `1688:restored:${row.skuCode || index}:${index}`,
|
||||
specs: Array.isArray(row.specs) ? row.specs : [],
|
||||
specsText: row.specsText || (Array.isArray(row.specs)
|
||||
? row.specs.map((spec) => `${spec.name}:${spec.value}`).join(' / ')
|
||||
: ''),
|
||||
price: row.price ?? row.groupPrice ?? null,
|
||||
coupon: row.coupon ?? defaults.coupon,
|
||||
activityDiscount: row.activityDiscount ?? defaults.activityDiscount,
|
||||
installFee: row.installFee ?? defaults.installFee,
|
||||
targetMargin: row.targetMargin ?? defaults.targetMargin,
|
||||
...DROPSHIP_FIXED_RATES,
|
||||
}));
|
||||
|
||||
const DropshipCalculator = ({
|
||||
embedded = false,
|
||||
productId: embeddedProductId = '',
|
||||
selectionPlanId = '',
|
||||
selectionItemId = '',
|
||||
selectionRows = [],
|
||||
onSelectionPricingSaved,
|
||||
} = {}) => {
|
||||
const [input, setInput] = useState(embeddedProductId);
|
||||
const [productDetail, setProductDetail] = useState(null);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const [fieldConfig, setFieldConfig] = useState(loadDropshipFieldConfig);
|
||||
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
|
||||
const [defaults, setDefaults] = useState(null);
|
||||
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
|
||||
const [defaultsOpen, setDefaultsOpen] = useState(false);
|
||||
const [batchDiscount, setBatchDiscount] = useState(null);
|
||||
const [defaultsSaving, setDefaultsSaving] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [saveNote, setSaveNote] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const autoLoadedProductRef = React.useRef('');
|
||||
const isSelectionPricingMode = Boolean(selectionPlanId && selectionItemId && embeddedProductId);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPricingPrefs('alibaba1688')
|
||||
.then((data) => {
|
||||
if (!cancelled) setDefaults(data?.prefs || null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDefaultsLoaded(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const effectiveDefaults = useMemo(
|
||||
() => ({ ...DROPSHIP_SYSTEM_DEFAULTS, ...(defaults || {}) }),
|
||||
[defaults]
|
||||
);
|
||||
|
||||
const computedRows = useMemo(
|
||||
() => rows.map((row) => ({ ...row, ...computeRow(row) })),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const dimensions = useMemo(
|
||||
() => getDropshipDimensions(productDetail, rows),
|
||||
[productDetail, rows]
|
||||
);
|
||||
|
||||
const dimensionOptions = useMemo(() => Object.fromEntries(dimensions.map((dimension) => [
|
||||
dimension,
|
||||
[...new Set(rows.map((row) => getDropshipSpecValue(row, dimension)).filter(Boolean))]
|
||||
.map((value) => ({ value, label: value })),
|
||||
])), [dimensions, rows]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const keyword = filters.keyword.trim().toLowerCase();
|
||||
return computedRows.filter((row) => {
|
||||
if (filters.available !== undefined && row.available !== filters.available) return false;
|
||||
if (Object.entries(filters.specs).some(([name, value]) => (
|
||||
value && getDropshipSpecValue(row, name) !== value
|
||||
))) return false;
|
||||
if (keyword && ![row.skuCode, row.name, row.specsText]
|
||||
.some((value) => String(value || '').toLowerCase().includes(keyword))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [computedRows, filters]);
|
||||
|
||||
const summaryMetrics = useMemo(() => {
|
||||
const margins = filteredRows.map((row) => row.marginRate).filter(Number.isFinite);
|
||||
const profits = filteredRows.map((row) => row.profitAmount).filter(Number.isFinite);
|
||||
const average = (values) => (values.length
|
||||
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
: null);
|
||||
return [
|
||||
{ key: 'count', label: '测算 SKU 数', value: `${filteredRows.length}` },
|
||||
{ key: 'margin', label: '平均毛利率', value: margins.length ? formatPercent(average(margins), 1) : '--' },
|
||||
{ key: 'profit', label: '平均营业利润额', value: profits.length ? `¥${formatMoney(average(profits))}` : '--' },
|
||||
{
|
||||
key: 'negativeProfit',
|
||||
label: '营业利润额为负(亏本)',
|
||||
value: `${filteredRows.filter((row) => Number.isFinite(row.profitAmount) && row.profitAmount < 0).length} 行`,
|
||||
},
|
||||
];
|
||||
}, [filteredRows]);
|
||||
|
||||
const runSearch = useCallback(async (rawInput) => {
|
||||
const parsed = parseAlibaba1688ProductId(rawInput);
|
||||
if (!parsed.productId) {
|
||||
setSearchError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
setSearchError('');
|
||||
try {
|
||||
const response = await getPlanningAlibaba1688ProductDetail(parsed.productId);
|
||||
if (response?.success === false) throw new Error(response.error || '1688 商品查询失败');
|
||||
const detail = response?.data || response;
|
||||
const nextRows = mergeDropshipSelectionPricingRows(
|
||||
createDropshipRows(detail, effectiveDefaults, {
|
||||
useSourceSkuCode: isSelectionPricingMode,
|
||||
}),
|
||||
selectionRows
|
||||
);
|
||||
if (!nextRows.length) {
|
||||
throw new Error(`商品「${parsed.productId}」没有可用于测算的 SKU 明细`);
|
||||
}
|
||||
setInput(parsed.productId);
|
||||
setProductDetail(detail);
|
||||
setRows(nextRows);
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
} catch (error) {
|
||||
setProductDetail(null);
|
||||
setRows([]);
|
||||
setSearchError(getFriendlyError(error, '1688 商品查询失败,请稍后重试'));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [effectiveDefaults, isSelectionPricingMode, selectionRows]);
|
||||
|
||||
useEffect(() => {
|
||||
const productId = String(embeddedProductId || '').trim();
|
||||
if (!defaultsLoaded || !productId || autoLoadedProductRef.current === productId) return;
|
||||
autoLoadedProductRef.current = productId;
|
||||
setInput(productId);
|
||||
runSearch(productId);
|
||||
}, [defaultsLoaded, embeddedProductId, runSearch]);
|
||||
|
||||
const updateRow = (id, patch) => {
|
||||
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
// 一键批量设置折扣:作用于当前筛选后的全部行
|
||||
const applyBatchDiscount = () => {
|
||||
if (!batchDiscount || batchDiscount <= 0) return;
|
||||
const ids = new Set(filteredRows.map((row) => row.id));
|
||||
setRows((current) => current.map((row) => (ids.has(row.id) ? { ...row, activityDiscount: batchDiscount } : row)));
|
||||
message.success(`已把 ${ids.size} 行的活动折扣统一设置为 ${batchDiscount}(即 ${batchDiscount * 10} 折)`);
|
||||
};
|
||||
|
||||
const resetToHero = () => {
|
||||
setProductDetail(null);
|
||||
setRows([]);
|
||||
setSearchError('');
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
};
|
||||
|
||||
const applyFilter = (patch) => {
|
||||
setFilters((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const applySpecFilter = (dimension, value) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
specs: { ...current.specs, [dimension]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
};
|
||||
|
||||
const handleSaveDefaults = async (form) => {
|
||||
setDefaultsSaving(true);
|
||||
try {
|
||||
const data = await savePricingPrefs(form, 'alibaba1688');
|
||||
const nextDefaults = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(data?.prefs || form) };
|
||||
setRows((current) => applyDropshipDefaultsToRows(current, defaults, nextDefaults));
|
||||
setDefaults(data?.prefs || form);
|
||||
setDefaultsOpen(false);
|
||||
message.success('1688 默认值已保存,未手动修改的行已同步更新');
|
||||
} catch (error) {
|
||||
message.error(getFriendlyError(error, '保存默认值配置失败'));
|
||||
} finally {
|
||||
setDefaultsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const product = productDetail?.product || productDetail || {};
|
||||
const productId = String(productDetail?.productId || product.productId || '');
|
||||
const productTitle = productDetail?.title || product.title || '';
|
||||
const productMainImage = productDetail?.mainImage || product.mainImage || rows[0]?.imageUrl || '';
|
||||
const productSourceUrl = productDetail?.sourceUrl || product.sourceUrl || '';
|
||||
|
||||
const formulaCell = { className: 'pricing-calc-td--formula' };
|
||||
const inputCell = { className: 'pricing-calc-td--input' };
|
||||
const pddCell = { className: 'pricing-calc-td--pdd' };
|
||||
const pddInputCell = { className: 'pricing-calc-td--pdd-input' };
|
||||
const autoHeader = { className: 'pricing-calc-th--auto' };
|
||||
const inputHeader = { className: 'pricing-calc-th--input' };
|
||||
const formulaHeader = { className: 'pricing-calc-th--formula' };
|
||||
const pddHeader = { className: 'pricing-calc-th--pdd' };
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '图片',
|
||||
dataIndex: 'imageUrl',
|
||||
width: 64,
|
||||
fixed: 'left',
|
||||
align: 'center',
|
||||
render: (value) => <ProductThumb url={value} />,
|
||||
},
|
||||
{
|
||||
title: '规格组合',
|
||||
dataIndex: 'specsText',
|
||||
width: 260,
|
||||
render: (_, record) => (
|
||||
<div className="pricing-calc-spec-list">
|
||||
{record.specs.length ? record.specs.map((spec, index) => (
|
||||
<span key={`${spec.name}-${spec.value}-${index}`} className="pricing-calc-spec-item">
|
||||
<b>{spec.name}</b>
|
||||
<span>{spec.value}</span>
|
||||
</span>
|
||||
)) : <span className="pricing-calc-text">-</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="1688 SKU 采购价,自动获取">采购成本</AutoHeaderTitle>,
|
||||
dataIndex: 'cost',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
sorter: numericSorter('cost'),
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="默认按(采购成本 + 运费)/ 0.55 计算,可填写;利润按优惠后实际到手价计算">到手价</AutoHeaderTitle>,
|
||||
dataIndex: 'price',
|
||||
width: 110,
|
||||
sorter: numericSorter('price'),
|
||||
onHeaderCell: () => inputHeader,
|
||||
onCell: () => inputCell,
|
||||
render: (value, record) => (
|
||||
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { price: next })} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="默认 0,可在默认值中配置">安装费</AutoHeaderTitle>,
|
||||
dataIndex: 'installFee',
|
||||
width: 96,
|
||||
onHeaderCell: () => inputHeader,
|
||||
onCell: () => inputCell,
|
||||
render: (value, record) => (
|
||||
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { installFee: next })} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="1688 SKU 运费,自动获取">运费</AutoHeaderTitle>,
|
||||
dataIndex: 'shipping',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
sorter: numericSorter('shipping'),
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '毛利率',
|
||||
dataIndex: 'marginRate',
|
||||
width: 92,
|
||||
align: 'right',
|
||||
sorter: numericSorter('marginRate'),
|
||||
onHeaderCell: () => formulaHeader,
|
||||
onCell: () => formulaCell,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="1688 代发固定使用非黑标店铺,平台佣金 1.5%">平台佣金</AutoHeaderTitle>,
|
||||
dataIndex: 'commission',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="毛利率减去固定费率、目标营业利润率及安装费占比">推广占比</AutoHeaderTitle>,
|
||||
dataIndex: 'promoRate',
|
||||
width: 104,
|
||||
align: 'right',
|
||||
sorter: numericSorter('promoRate'),
|
||||
onHeaderCell: () => formulaHeader,
|
||||
onCell: () => formulaCell,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="固定 2.5%">营销广告费</AutoHeaderTitle>,
|
||||
dataIndex: 'adFee',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="固定 0%">种菜占比</AutoHeaderTitle>,
|
||||
dataIndex: 'seeding',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="固定 5%">售后费用</AutoHeaderTitle>,
|
||||
dataIndex: 'afterSale',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="固定 5%">保险费</AutoHeaderTitle>,
|
||||
dataIndex: 'insurance',
|
||||
width: 92,
|
||||
align: 'right',
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="1688 代发不收取品牌使用费,固定 0%">品牌使用费</AutoHeaderTitle>,
|
||||
dataIndex: 'brandFee',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
onHeaderCell: () => autoHeader,
|
||||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="目标营业利润率默认 12%,可填写">营业利润率</AutoHeaderTitle>,
|
||||
dataIndex: 'targetMargin',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
onHeaderCell: () => inputHeader,
|
||||
onCell: () => inputCell,
|
||||
render: (value, record) => (
|
||||
<PercentInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { targetMargin: next })} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '营业利润额',
|
||||
dataIndex: 'profitAmount',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
fixed: 'right',
|
||||
sorter: numericSorter('profitAmount'),
|
||||
onHeaderCell: () => formulaHeader,
|
||||
onCell: (record) => ({
|
||||
className: Number.isFinite(record.profitAmount) && record.profitAmount < 0
|
||||
? 'pricing-calc-td--danger'
|
||||
: 'pricing-calc-td--formula',
|
||||
}),
|
||||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="优惠券金额默认 20,可填写">优惠券金额</AutoHeaderTitle>,
|
||||
dataIndex: 'coupon',
|
||||
width: 104,
|
||||
align: 'right',
|
||||
onHeaderCell: () => pddHeader,
|
||||
onCell: () => pddInputCell,
|
||||
render: (value, record) => (
|
||||
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { coupon: next })} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="活动折扣默认 0.6(即 6 折),可填写">活动折扣</AutoHeaderTitle>,
|
||||
dataIndex: 'activityDiscount',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
onHeaderCell: () => pddHeader,
|
||||
onCell: () => pddInputCell,
|
||||
render: (value, record) => (
|
||||
<DiscountInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { activityDiscount: next })} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="公式:(到手价 + 优惠券金额) / 活动折扣">拼单价</AutoHeaderTitle>,
|
||||
dataIndex: 'pddPrice',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
fixed: 'right',
|
||||
sorter: numericSorter('pddPrice'),
|
||||
onHeaderCell: () => pddHeader,
|
||||
onCell: () => pddCell,
|
||||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: <AutoHeaderTitle tip="拼单价向上取整">最终拼单价</AutoHeaderTitle>,
|
||||
dataIndex: 'pddFinalPrice',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
fixed: 'right',
|
||||
sorter: numericSorter('pddFinalPrice'),
|
||||
onHeaderCell: () => pddHeader,
|
||||
onCell: () => pddCell,
|
||||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const columnsByKey = new Map(columns.map((column) => [column.dataIndex, {
|
||||
...column,
|
||||
key: column.dataIndex,
|
||||
}]));
|
||||
const visibleColumns = fieldConfig.order
|
||||
.filter((key) => !fieldConfig.hidden.includes(key) && columnsByKey.has(key))
|
||||
.map((key) => columnsByKey.get(key));
|
||||
const tableScrollX = visibleColumns.reduce((sum, column) => sum + (column.width || 100), 0) + 40;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!productId || filteredRows.length === 0) {
|
||||
message.warning('没有可导出的数据');
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
const exportColumns = visibleColumns.map((column) => ({
|
||||
key: column.dataIndex,
|
||||
label: DROPSHIP_FIELD_LABELS[column.dataIndex] || column.dataIndex,
|
||||
percent: PERCENT_FIELD_KEYS.has(column.dataIndex),
|
||||
}));
|
||||
const exportRows = filteredRows.map((row) => Object.fromEntries(
|
||||
exportColumns.map(({ key }) => [key, row[key]])
|
||||
));
|
||||
const blob = await exportPricingTable({ spu: productId, columns: exportColumns, rows: exportRows });
|
||||
const stamp = new Date();
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `1688代发测算_${productId}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
message.success(`已导出 ${filteredRows.length} 行 × ${exportColumns.length} 列`);
|
||||
} catch (error) {
|
||||
message.error(getFriendlyError(error, '导出失败,请稍后重试'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await savePricingHistory({
|
||||
calculatorType: 'alibaba1688',
|
||||
spuCode: productId,
|
||||
selectionContext: isSelectionPricingMode ? {
|
||||
planId: selectionPlanId,
|
||||
itemId: selectionItemId,
|
||||
workflowProfile: 'alibaba_1688_pending',
|
||||
} : undefined,
|
||||
brand: productDetail?.storeName || product.storeName || '',
|
||||
note: saveNote.trim(),
|
||||
product: {
|
||||
productId,
|
||||
title: productTitle,
|
||||
mainImage: productMainImage,
|
||||
sourceUrl: productSourceUrl,
|
||||
storeName: productDetail?.storeName || product.storeName || '',
|
||||
category: productDetail?.category || product.category || '',
|
||||
skuDimensions: product.skuDimensions || [],
|
||||
},
|
||||
summary: {
|
||||
rowCount: computedRows.length,
|
||||
negativeProfitCount: computedRows.filter((row) => (
|
||||
Number.isFinite(row.profitAmount) && row.profitAmount < 0
|
||||
)).length,
|
||||
},
|
||||
rows: computedRows,
|
||||
});
|
||||
setSaveModalOpen(false);
|
||||
setHistoryRefreshKey((key) => key + 1);
|
||||
if (isSelectionPricingMode && typeof onSelectionPricingSaved === 'function') {
|
||||
await onSelectionPricingSaved();
|
||||
}
|
||||
message.success(isSelectionPricingMode ? '1688 定价表已保存' : '1688 代发测算记录已保存,可在测算历史中还原');
|
||||
} catch (error) {
|
||||
message.error(getFriendlyError(error, '保存测算记录失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (record) => {
|
||||
setRestoring(true);
|
||||
try {
|
||||
const detail = await fetchPricingHistoryDetail(record.id);
|
||||
const payload = detail?.payload;
|
||||
if (!payload || payload.calculatorType !== 'alibaba1688' || !Array.isArray(payload.rows)) {
|
||||
throw new Error('该记录不是 1688 代发测算记录,无法在当前页签还原');
|
||||
}
|
||||
const restoredProduct = payload.product || { productId: payload.spuCode || detail.spuCode };
|
||||
setProductDetail({
|
||||
...restoredProduct,
|
||||
productId: restoredProduct.productId || payload.spuCode || detail.spuCode,
|
||||
restoredAt: detail.createdAt,
|
||||
product: restoredProduct,
|
||||
});
|
||||
setRows(normalizeRestoredRows(payload.rows, effectiveDefaults));
|
||||
setInput(restoredProduct.productId || payload.spuCode || detail.spuCode || '');
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setHistoryOpen(false);
|
||||
message.success(`已还原商品 ${detail.spuCode} 的测算记录`);
|
||||
} catch (error) {
|
||||
message.error(getFriendlyError(error, '还原测算记录失败'));
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderHero = () => (
|
||||
<div className="pricing-calc-hero pricing-calc-dropship-hero">
|
||||
<div className="pricing-calc-hero__icon">
|
||||
<ShopOutlined />
|
||||
</div>
|
||||
<h2 className="pricing-calc-hero__title">1688代发拼多多测算</h2>
|
||||
<p className="pricing-calc-hero__subtitle">
|
||||
输入 1688 商品 ID 或商品详情链接,读取全部 SKU 的采购价、规格、图片与运费,按非黑标拼多多店铺规则完成售价与利润测算。
|
||||
</p>
|
||||
<Input.Search
|
||||
className="pricing-calc-hero__search"
|
||||
size="large"
|
||||
placeholder="输入商品 ID 或 detail.1688.com/offer/... 链接"
|
||||
enterButton="开始测算"
|
||||
loading={searching}
|
||||
value={input}
|
||||
readOnly={isSelectionPricingMode}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onSearch={runSearch}
|
||||
/>
|
||||
{searchError ? (
|
||||
<Alert className="pricing-calc-hero__error" type="warning" showIcon message={searchError} />
|
||||
) : null}
|
||||
<div className="pricing-calc-hero__footer">
|
||||
<span className="pricing-calc-hero__hint">平台佣金 1.5% · 营销广告 2.5% · 营业利润率默认 12%</span>
|
||||
{!embedded ? <Button type="link" icon={<HistoryOutlined />} loading={restoring} onClick={() => setHistoryOpen(true)}>
|
||||
测算历史
|
||||
</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderResult = () => (
|
||||
<div>
|
||||
<div className="pricing-calc-spubar pricing-calc-dropship-spubar">
|
||||
<div className="pricing-calc-product-summary">
|
||||
<ProductThumb url={productMainImage} size={52} />
|
||||
<div className="pricing-calc-product-summary__body">
|
||||
<Tooltip title={productTitle}>
|
||||
<strong className="pricing-calc-product-summary__title">{productTitle || '1688 商品'}</strong>
|
||||
</Tooltip>
|
||||
<div className="pricing-calc-spubar__meta">
|
||||
<Tag color="orange" className="pricing-calc-spubar__spu">{productId}</Tag>
|
||||
<span>{rows.length} 个 SKU</span>
|
||||
<span>{dimensions.length} 个规格组</span>
|
||||
{productDetail?.storeName || product.storeName ? (
|
||||
<span>厂家:{productDetail?.storeName || product.storeName}</span>
|
||||
) : null}
|
||||
{productDetail?.restoredAt ? <span>历史还原</span> : null}
|
||||
<span>佣金 1.5%</span>
|
||||
<span>种菜 0%</span>
|
||||
<span>品牌费 0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pricing-calc-spubar__content">
|
||||
<div className="pricing-calc-spubar__controls">
|
||||
<Input.Search
|
||||
className="pricing-calc-spubar__search"
|
||||
placeholder="商品 ID 或 1688 商品链接"
|
||||
enterButton="测算"
|
||||
loading={searching}
|
||||
value={input}
|
||||
readOnly={isSelectionPricingMode}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onSearch={runSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-calc-spubar__actions">
|
||||
{!embedded ? <Button icon={<ReloadOutlined />} onClick={resetToHero}>新的测算</Button> : null}
|
||||
{!embedded ? <Button icon={<HistoryOutlined />} loading={restoring} onClick={() => setHistoryOpen(true)}>测算历史</Button> : null}
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title="批量设置活动折扣"
|
||||
content={(
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
max={1}
|
||||
step={0.05}
|
||||
precision={2}
|
||||
placeholder="如 0.6 = 6 折"
|
||||
style={{ width: 120 }}
|
||||
value={batchDiscount}
|
||||
onChange={(value) => setBatchDiscount(value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
disabled={!batchDiscount || batchDiscount <= 0 || filteredRows.length === 0}
|
||||
onClick={applyBatchDiscount}
|
||||
>
|
||||
应用到全部行({filteredRows.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button icon={<CalculatorOutlined />}>批量折扣</Button>
|
||||
</Popover>
|
||||
<Button icon={<ControlOutlined />} onClick={() => setDefaultsOpen(true)}>默认值</Button>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExport}>导出</Button>
|
||||
<Button icon={<SettingOutlined />} onClick={() => setFieldConfigOpen(true)}>字段配置</Button>
|
||||
{productSourceUrl ? (
|
||||
<Button icon={<ExportOutlined />} href={productSourceUrl} target="_blank" rel="noreferrer">查看 1688 商品</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
disabled={rows.length === 0}
|
||||
onClick={() => {
|
||||
setSaveNote('');
|
||||
setSaveModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{isSelectionPricingMode ? '保存定价表' : '保存测算记录'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pricing-calc-summary" aria-label="1688 代发定价测算摘要">
|
||||
{summaryMetrics.map((item) => (
|
||||
<SycmDashboardMetric key={item.key} label={item.label} value={item.value} help={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pricing-calc-filter">
|
||||
<span className="pricing-calc-filter__item">
|
||||
<label>采购状态</label>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 112 }}
|
||||
placeholder="全部"
|
||||
value={filters.available}
|
||||
options={[
|
||||
{ value: true, label: '可采购' },
|
||||
{ value: false, label: '不可采购' },
|
||||
]}
|
||||
onChange={(value) => applyFilter({ available: value })}
|
||||
/>
|
||||
</span>
|
||||
{dimensions.map((dimension) => (
|
||||
<span key={dimension} className="pricing-calc-filter__item">
|
||||
<label>{dimension}</label>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 150 }}
|
||||
placeholder="全部"
|
||||
value={filters.specs[dimension]}
|
||||
options={dimensionOptions[dimension] || []}
|
||||
onChange={(value) => applySpecFilter(dimension, value)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
<Input
|
||||
allowClear
|
||||
className="pricing-calc-filter__keyword"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="SKU 名称 / 规格"
|
||||
value={filters.keyword}
|
||||
onChange={(event) => applyFilter({ keyword: event.target.value })}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>重置</Button>
|
||||
<span className="pricing-calc-legend">
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<span key={item.className} className="pricing-calc-legend__item">
|
||||
<i className={`pricing-calc-chip ${item.className}`} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
className="pricing-calc-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={visibleColumns}
|
||||
dataSource={filteredRows}
|
||||
scroll={{ x: tableScrollX, scrollToFirstRowOnChange: true }}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="没有匹配的 SKU,请调整筛选条件" /> }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{productDetail ? renderResult() : renderHero()}
|
||||
|
||||
<Modal
|
||||
title={isSelectionPricingMode ? '保存 1688 上架定价表' : '保存 1688 代发测算记录'}
|
||||
open={saveModalOpen}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={saving}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setSaveModalOpen(false)}
|
||||
>
|
||||
<p className="pricing-calc-save__hint">
|
||||
{isSelectionPricingMode
|
||||
? `将保存商品「${productId}」共 ${rows.length} 行定价,并写回当前上架任务。`
|
||||
: `将保存商品「${productId}」共 ${rows.length} 行的完整测算快照,仅自己可见。`}
|
||||
</p>
|
||||
<Input
|
||||
value={saveNote}
|
||||
maxLength={200}
|
||||
placeholder="备注(可选),如:首轮代发定价"
|
||||
onChange={(event) => setSaveNote(event.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<DefaultsConfigModal
|
||||
open={defaultsOpen}
|
||||
onClose={() => setDefaultsOpen(false)}
|
||||
defaults={defaults}
|
||||
systemDefaults={DROPSHIP_SYSTEM_DEFAULTS}
|
||||
saving={defaultsSaving}
|
||||
onSave={handleSaveDefaults}
|
||||
title="1688 代发默认值配置"
|
||||
/>
|
||||
<FieldConfigDrawer
|
||||
open={fieldConfigOpen}
|
||||
onClose={() => setFieldConfigOpen(false)}
|
||||
config={fieldConfig}
|
||||
fieldLabels={DROPSHIP_FIELD_LABELS}
|
||||
createDefaultConfig={createDefaultDropshipFieldConfig}
|
||||
onChange={(next) => {
|
||||
setFieldConfig(next);
|
||||
saveDropshipFieldConfig(next);
|
||||
}}
|
||||
/>
|
||||
<HistoryDrawer
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRestore={handleRestore}
|
||||
refreshKey={historyRefreshKey}
|
||||
calculatorType="alibaba1688"
|
||||
title="1688 代发测算历史"
|
||||
identityLabel="商品 ID"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropshipCalculator;
|
||||
90
frontend/src/pages/PricingCalculator/FieldConfigDrawer.jsx
Normal file
90
frontend/src/pages/PricingCalculator/FieldConfigDrawer.jsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Checkbox, Drawer } from 'antd';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import { createDefaultFieldConfig, FIELD_LABELS } from './fieldConfig';
|
||||
|
||||
/**
|
||||
* 字段配置抽屉:
|
||||
* - 勾选控制列显隐
|
||||
* - 拖动把手(整行可拖)调整列顺序,表格实时按新顺序渲染
|
||||
* - 配置即改即存(localStorage),刷新后保持
|
||||
*/
|
||||
const FieldConfigDrawer = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
fieldLabels = FIELD_LABELS,
|
||||
createDefaultConfig = createDefaultFieldConfig,
|
||||
tip = '勾选控制列显示,拖动调整列顺序;配置自动保存在本机。',
|
||||
}) => {
|
||||
const [dragIndex, setDragIndex] = useState(null);
|
||||
|
||||
const moveField = (from, to) => {
|
||||
if (from === to) return;
|
||||
const order = [...config.order];
|
||||
const [item] = order.splice(from, 1);
|
||||
order.splice(to, 0, item);
|
||||
onChange({ ...config, order });
|
||||
};
|
||||
|
||||
const toggleField = (key, checked) => {
|
||||
const hidden = checked
|
||||
? config.hidden.filter((item) => item !== key)
|
||||
: [...config.hidden, key];
|
||||
onChange({ ...config, hidden });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="字段配置"
|
||||
width={360}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<div className="pricing-calc-field-config__footer">
|
||||
<Button onClick={() => onChange(createDefaultConfig())}>恢复默认</Button>
|
||||
<Button type="primary" onClick={onClose}>完成</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<p className="pricing-calc-field-config__tip">
|
||||
{tip}
|
||||
</p>
|
||||
<ul className="pricing-calc-field-config__list">
|
||||
{config.order.map((key, index) => {
|
||||
const isHidden = config.hidden.includes(key);
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className={`pricing-calc-field-config__item${index === dragIndex ? ' is-dragging' : ''}${isHidden ? ' is-off' : ''}`}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
setDragIndex(index);
|
||||
}}
|
||||
onDragEnter={() => {
|
||||
if (dragIndex !== null && dragIndex !== index) {
|
||||
moveField(dragIndex, index);
|
||||
setDragIndex(index);
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragEnd={() => setDragIndex(null)}
|
||||
>
|
||||
<HolderOutlined className="pricing-calc-field-config__grip" />
|
||||
<Checkbox
|
||||
checked={!isHidden}
|
||||
onChange={(event) => toggleField(key, event.target.checked)}
|
||||
>
|
||||
{fieldLabels[key] || key}
|
||||
</Checkbox>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldConfigDrawer;
|
||||
177
frontend/src/pages/PricingCalculator/HistoryDrawer.jsx
Normal file
177
frontend/src/pages/PricingCalculator/HistoryDrawer.jsx
Normal file
@ -0,0 +1,177 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Drawer, Empty, List, Popconfirm, Spin, Tag, Tooltip, message } from 'antd';
|
||||
import { DeleteOutlined, HistoryOutlined, RollbackOutlined } from '@ant-design/icons';
|
||||
import { deletePricingHistory, fetchPricingHistoryList } from '../../api/pricingCalculator';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const getIsAdmin = () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return false;
|
||||
const [, payload] = token.split('.');
|
||||
if (!payload) return false;
|
||||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '=');
|
||||
return JSON.parse(window.atob(padded))?.role === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 测算历史抽屉:服务端已按当前登录用户过滤,这里看到的全部是自己的记录。
|
||||
* onRestore(record) 由父组件拉取详情并还原测算状态。
|
||||
*/
|
||||
const HistoryDrawer = ({
|
||||
open,
|
||||
onClose,
|
||||
onRestore,
|
||||
refreshKey,
|
||||
calculatorType = 'company',
|
||||
title = '测算历史',
|
||||
identityLabel = 'SPU',
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [isAdmin] = useState(getIsAdmin);
|
||||
|
||||
const load = useCallback(async (nextPage = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchPricingHistoryList({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
calculatorType,
|
||||
});
|
||||
setList(data?.list || []);
|
||||
setTotal(data?.total || 0);
|
||||
setPage(data?.page || nextPage);
|
||||
} catch (error) {
|
||||
message.error(error.message || '查询测算历史失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [calculatorType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load(1);
|
||||
}
|
||||
}, [open, refreshKey, load]);
|
||||
|
||||
const handleDelete = async (record) => {
|
||||
setDeletingId(record.id);
|
||||
try {
|
||||
await deletePricingHistory(record.id);
|
||||
message.success('已删除该测算记录');
|
||||
// 删除后若当前页已空,回退一页
|
||||
const nextPage = list.length === 1 && page > 1 ? page - 1 : page;
|
||||
load(nextPage);
|
||||
} catch (error) {
|
||||
message.error(error.message || '删除失败');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={(
|
||||
<span className="pricing-calc-history__title">
|
||||
<HistoryOutlined />
|
||||
{title}
|
||||
<span className="pricing-calc-history__subtitle">
|
||||
{isAdmin ? '管理员视图 · 显示全部记录' : '仅显示我的测算记录'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="pricing-calc-history"
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{list.length === 0 && !loading ? (
|
||||
<Empty description={`暂无${title},完成一次测算后点击「保存测算记录」`} />
|
||||
) : (
|
||||
<List
|
||||
dataSource={list}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
total,
|
||||
size: 'small',
|
||||
onChange: (nextPage) => load(nextPage),
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
}}
|
||||
renderItem={(record) => (
|
||||
<List.Item
|
||||
className="pricing-calc-history__item"
|
||||
actions={[
|
||||
<Button
|
||||
key="restore"
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<RollbackOutlined />}
|
||||
onClick={() => onRestore(record)}
|
||||
>
|
||||
还原
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="删除该测算记录?"
|
||||
description="删除后无法恢复。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
loading={deletingId === record.id}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="删除记录"
|
||||
/>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={(
|
||||
<span className="pricing-calc-history__item-title">
|
||||
<Tooltip title={`${identityLabel}:${record.spuCode}`}>
|
||||
<span className="pricing-calc-history__spu">{record.spuCode}</span>
|
||||
</Tooltip>
|
||||
{record.brand ? <Tag>{record.brand}</Tag> : null}
|
||||
<span className="pricing-calc-history__count">{record.rowCount} 行</span>
|
||||
</span>
|
||||
)}
|
||||
description={(
|
||||
<span className="pricing-calc-history__meta">
|
||||
<span>{formatTime(record.createdAt)}</span>
|
||||
{isAdmin && record.userName ? <span>{record.userName}</span> : null}
|
||||
{record.note ? <span className="pricing-calc-history__note">{record.note}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryDrawer;
|
||||
80
frontend/src/pages/PricingCalculator/calc.js
Normal file
80
frontend/src/pages/PricingCalculator/calc.js
Normal file
@ -0,0 +1,80 @@
|
||||
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const RATE_FIELD_KEYS = ['commission', 'adFee', 'seeding', 'afterSale', 'insurance', 'brandFee', 'targetMargin'];
|
||||
|
||||
/**
|
||||
* 核心公式(L=到手价 M=安装费 N=成本 O=运费):
|
||||
* P 毛利率 = (L - N - O) / L
|
||||
* R 推广占比 = P - Q - S - T - U - V - W - Y - M / L(纯公式列,只读不可编辑)
|
||||
* X 营业利润额 = L - M - N - O - (Q + R + S + T + U + V + W) * L
|
||||
* 拼多多活动:拼单价 = (L + 优惠券金额) / 活动折扣;最终拼单价 = 拼单价向上取整,
|
||||
* 并写回 groupPrice,保存定价表时作为自动上架 SKU 最终定价
|
||||
* 输入不完整时返回 null,页面显示 "-" 而不是 NaN。
|
||||
*/
|
||||
export const computeRow = (row) => {
|
||||
// L 到手价:优先 price;兼容自动上架早期数据只有 groupPrice 的情况
|
||||
const L = row.price ?? row.groupPrice;
|
||||
const { installFee: M, cost: N, shipping: O } = row;
|
||||
const result = {
|
||||
marginRate: null, promoRate: null, promoRateAuto: null, profitAmount: null,
|
||||
pddPrice: null, pddFinalPrice: null, groupPrice: null,
|
||||
};
|
||||
|
||||
if (!isFiniteNumber(L) || L <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 拼单价只依赖到手价/优惠券/活动折扣,与其他费率无关
|
||||
if (isFiniteNumber(row.coupon) && isFiniteNumber(row.activityDiscount) && row.activityDiscount > 0) {
|
||||
result.pddPrice = (L + row.coupon) / row.activityDiscount;
|
||||
result.pddFinalPrice = Math.ceil(result.pddPrice);
|
||||
}
|
||||
// groupPrice 供自动上架保存使用:取最终拼单价,缺优惠券/折扣时兜底到手价
|
||||
result.groupPrice = result.pddFinalPrice ?? L;
|
||||
|
||||
if (!isFiniteNumber(N) || !isFiniteNumber(O)) {
|
||||
return result;
|
||||
}
|
||||
const marginRate = (L - N - O) / L;
|
||||
result.marginRate = marginRate;
|
||||
|
||||
const ratesReady = isFiniteNumber(M) && RATE_FIELD_KEYS.every((key) => isFiniteNumber(row[key]));
|
||||
if (!ratesReady) {
|
||||
return result;
|
||||
}
|
||||
const promoRateAuto = marginRate
|
||||
- row.commission
|
||||
- row.adFee
|
||||
- row.seeding
|
||||
- row.afterSale
|
||||
- row.insurance
|
||||
- row.brandFee
|
||||
- row.targetMargin
|
||||
- M / L;
|
||||
result.promoRateAuto = promoRateAuto;
|
||||
result.promoRate = promoRateAuto;
|
||||
|
||||
const rateSum = row.commission + promoRateAuto + row.adFee + row.seeding
|
||||
+ row.afterSale + row.insurance + row.brandFee;
|
||||
result.profitAmount = L - M - N - O - rateSum * L;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const toPercentInputValue = (rate) => (
|
||||
isFiniteNumber(rate) ? Number((rate * 100).toFixed(4)) : null
|
||||
);
|
||||
|
||||
export const formatMoney = (value) => (isFiniteNumber(value) ? value.toFixed(2) : '-');
|
||||
|
||||
export const formatPercent = (value, digits = 2) => (
|
||||
isFiniteNumber(value) ? `${(value * 100).toFixed(digits)}%` : '-'
|
||||
);
|
||||
|
||||
export const formatDateTime = (ts) => {
|
||||
const num = Number(ts);
|
||||
if (!Number.isFinite(num) || num <= 0) return '-';
|
||||
const date = new Date(num);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
136
frontend/src/pages/PricingCalculator/constants.js
Normal file
136
frontend/src/pages/PricingCalculator/constants.js
Normal file
@ -0,0 +1,136 @@
|
||||
export const STORAGE_KEYS = {
|
||||
brands: 'pdd_pricing_brand_configs_v1',
|
||||
};
|
||||
|
||||
// 比例类字段默认值(小数存储,页面以百分比展示)
|
||||
export const PLATFORM_COMMISSION_RATES = Object.freeze({
|
||||
standard: 0.015,
|
||||
blackLabel: 0.06,
|
||||
});
|
||||
|
||||
export const getPlatformCommissionRate = (isBlackLabelShop) => (
|
||||
isBlackLabelShop ? PLATFORM_COMMISSION_RATES.blackLabel : PLATFORM_COMMISSION_RATES.standard
|
||||
);
|
||||
|
||||
export const inferBlackLabelShop = (rows = []) => {
|
||||
const commission = rows
|
||||
.map((row) => row?.commission ?? row?.savedPricing?.commission)
|
||||
.map((value) => toFiniteNumber(value))
|
||||
.find((value) => value !== null);
|
||||
return commission !== undefined
|
||||
&& Math.abs(commission - PLATFORM_COMMISSION_RATES.blackLabel) < 1e-9;
|
||||
};
|
||||
|
||||
export const DEFAULT_RATES = {
|
||||
commission: PLATFORM_COMMISSION_RATES.standard, // 平台佣金:黑标店铺 6% / 非黑标 1.5%
|
||||
adFee: 0.025, // 营销广告费
|
||||
seeding: 0.005, // 种菜占比
|
||||
afterSale: 0.05, // 售后费用
|
||||
insurance: 0.05, // 保险费
|
||||
brandFee: 0, // 品牌使用费:默认 0,按条码前缀自动判断(见 deriveBrandFromBarcode)
|
||||
targetMargin: 0.15, // 营业利润率(目标值)
|
||||
};
|
||||
|
||||
// 条码前缀规则:GBGJ- 顾家(品牌使用费 7%)、GBSFY- 索菲亚(6%)、其余简尚(0)
|
||||
export const BARCODE_BRAND_RULES = [
|
||||
{ prefix: 'GBGJ-', brand: '顾家', brandFee: 0.07 },
|
||||
{ prefix: 'GBSFY-', brand: '索菲亚', brandFee: 0.06 },
|
||||
];
|
||||
|
||||
export const deriveBrandFromBarcode = (barcode) => {
|
||||
const code = String(barcode || '').trim().toUpperCase();
|
||||
const matched = BARCODE_BRAND_RULES.find((rule) => code.startsWith(rule.prefix));
|
||||
return matched
|
||||
? { brand: matched.brand, brandFee: matched.brandFee }
|
||||
: { brand: '简尚', brandFee: 0 };
|
||||
};
|
||||
|
||||
// 系统兜底默认值(运营未保存个性化默认值配置时使用,见 DefaultsConfigModal)
|
||||
export const SYSTEM_DEFAULTS = {
|
||||
coupon: 20, // 优惠券金额
|
||||
activityDiscount: 0.6, // 活动折扣(即 6 折)
|
||||
installFee: 0, // 安装费
|
||||
targetMargin: 0.15, // 营业利润率
|
||||
};
|
||||
|
||||
export const SOURCE_LABELS = {
|
||||
getJstSkusBySpu: '聚水潭实时接口',
|
||||
};
|
||||
|
||||
export const formatSourceLabel = (source) => {
|
||||
if (!source) return '-';
|
||||
return SOURCE_LABELS[source] || `本地快照(${source})`;
|
||||
};
|
||||
|
||||
export const createRowId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 由 SPU 条码行生成测算行:
|
||||
* 条码/图片/尺寸/颜色/成本/公司限价自动填入;运费优先取运费画像的全国运费;
|
||||
* 售后占比优先取画像值;到手价默认取公司限价(无限价时取成本价的 2 倍,均可改);
|
||||
* 品牌与品牌使用费按条码前缀自动判断(GBGJ- 顾家 7% / GBSFY- 索菲亚 6% / 其余简尚 0)。
|
||||
*/
|
||||
export const createRowFromSku = (sku, { freightProfile, defaults, commission } = {}) => {
|
||||
const minimumPrices = freightProfile?.minimumPricesBySku || {};
|
||||
const freightMinimum = minimumPrices[sku.skuCode]
|
||||
?? minimumPrices[String(sku.skuCode || '').toUpperCase()]
|
||||
?? minimumPrices[String(sku.skuCode || '').toLowerCase()];
|
||||
const { brand, brandFee } = deriveBrandFromBarcode(sku.skuCode);
|
||||
const priceLimit = toFiniteNumber(freightMinimum ?? sku.minimumPrice);
|
||||
const cost = toFiniteNumber(sku.cost);
|
||||
|
||||
return {
|
||||
id: createRowId(),
|
||||
barcode: sku.skuCode || '',
|
||||
skuStatus: sku.status || '',
|
||||
stock: toFiniteNumber(sku.stock),
|
||||
stockWarehouses: Array.isArray(sku.stockWarehouses) ? sku.stockWarehouses : [],
|
||||
originalSkuName: sku.savedPricing?.originalSkuName || sku.name || '',
|
||||
selectedSkuName: sku.savedPricing?.selectedSkuName || sku.name || '',
|
||||
name: sku.savedPricing?.selectedSkuName || sku.name || '',
|
||||
internalProductCode: sku.savedPricing?.internalProductCode || '',
|
||||
productCodeProductName: sku.savedPricing?.productCodeProductName || '',
|
||||
productCodeColorSpec: sku.savedPricing?.productCodeColorSpec || '',
|
||||
imageUrl: sku.image || '',
|
||||
size: sku.size || '',
|
||||
color: sku.color || '',
|
||||
supplier: sku.supplier || '',
|
||||
category: sku.category || '',
|
||||
lengthCm: toFiniteNumber(sku.lengthCm),
|
||||
widthCm: toFiniteNumber(sku.widthCm),
|
||||
heightCm: toFiniteNumber(sku.heightCm),
|
||||
weight: toFiniteNumber(sku.weight),
|
||||
brand,
|
||||
companyPriceLimit: priceLimit,
|
||||
salePrice: toFiniteNumber(sku.salePrice),
|
||||
price: toFiniteNumber(sku.savedPricing?.price)
|
||||
?? toFiniteNumber(sku.savedPricing?.groupPrice) // 早期自动上架只存了拼单价,兜底还原为到手价
|
||||
?? priceLimit
|
||||
?? (cost === null ? null : cost * 2), // L 到手价(默认取公司限价,无限价时取成本价的 2 倍,可改)
|
||||
// 默认值优先级:选品已存价 > 用户个性化配置 > 系统默认(券 20 / 折扣 0.6 / 安装费 0 / 利润率 15%)
|
||||
installFee: toFiniteNumber(sku.savedPricing?.installFee) ?? toFiniteNumber(defaults?.installFee) ?? 0,
|
||||
coupon: toFiniteNumber(sku.savedPricing?.coupon) ?? toFiniteNumber(defaults?.coupon) ?? 20,
|
||||
activityDiscount: toFiniteNumber(sku.savedPricing?.activityDiscount) ?? toFiniteNumber(defaults?.activityDiscount) ?? 0.6,
|
||||
cost, // N 成本
|
||||
// O 运费:优先取接口按条码返回的全国平均运费(qgyf),其次运费画像的全国运费
|
||||
shipping: toFiniteNumber(sku.savedPricing?.shipping)
|
||||
?? toFiniteNumber(sku.freight)
|
||||
?? toFiniteNumber(freightProfile?.nationalFreight),
|
||||
...DEFAULT_RATES,
|
||||
commission: toFiniteNumber(commission)
|
||||
?? toFiniteNumber(sku.savedPricing?.commission)
|
||||
?? DEFAULT_RATES.commission,
|
||||
adFee: toFiniteNumber(sku.savedPricing?.adFee) ?? DEFAULT_RATES.adFee,
|
||||
seeding: toFiniteNumber(sku.savedPricing?.seeding) ?? DEFAULT_RATES.seeding,
|
||||
afterSale: toFiniteNumber(sku.savedPricing?.afterSale) ?? DEFAULT_RATES.afterSale,
|
||||
insurance: toFiniteNumber(sku.savedPricing?.insurance) ?? DEFAULT_RATES.insurance,
|
||||
targetMargin: toFiniteNumber(sku.savedPricing?.targetMargin) ?? toFiniteNumber(defaults?.targetMargin) ?? DEFAULT_RATES.targetMargin,
|
||||
brandFee: toFiniteNumber(sku.savedPricing?.brandFee) ?? brandFee,
|
||||
};
|
||||
};
|
||||
176
frontend/src/pages/PricingCalculator/dropship.js
Normal file
176
frontend/src/pages/PricingCalculator/dropship.js
Normal file
@ -0,0 +1,176 @@
|
||||
export const DROPSHIP_SYSTEM_DEFAULTS = Object.freeze({
|
||||
coupon: 20,
|
||||
activityDiscount: 0.6,
|
||||
installFee: 0,
|
||||
targetMargin: 0.12,
|
||||
});
|
||||
|
||||
export const DROPSHIP_FIXED_RATES = Object.freeze({
|
||||
commission: 0.015,
|
||||
adFee: 0.025,
|
||||
seeding: 0,
|
||||
afterSale: 0.05,
|
||||
insurance: 0.05,
|
||||
brandFee: 0,
|
||||
});
|
||||
|
||||
const RAW_PRODUCT_ID_PATTERN = /^[0-9]{6,80}$/;
|
||||
const PRODUCT_PATH_PATTERN = /\/offer\/([0-9]{6,80})(?:\.html)?(?:\/|$)/i;
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
};
|
||||
|
||||
export const parseAlibaba1688ProductId = (value) => {
|
||||
const input = String(value || '').trim();
|
||||
if (!input) {
|
||||
return { productId: '', error: '请输入 1688 商品 ID 或商品链接' };
|
||||
}
|
||||
if (RAW_PRODUCT_ID_PATTERN.test(input)) {
|
||||
return { productId: input, error: '' };
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
return { productId: '', error: '格式不正确,请输入数字商品 ID 或 1688 商品详情链接' };
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (hostname !== '1688.com' && !hostname.endsWith('.1688.com')) {
|
||||
return { productId: '', error: '仅支持 1688 商品详情链接' };
|
||||
}
|
||||
const matched = url.pathname.match(PRODUCT_PATH_PATTERN);
|
||||
if (!matched) {
|
||||
return { productId: '', error: '该链接不是 1688 商品详情链接,请使用包含 /offer/商品ID.html 的链接' };
|
||||
}
|
||||
return { productId: matched[1], error: '' };
|
||||
};
|
||||
|
||||
const normalizeSpecs = (specs) => (Array.isArray(specs) ? specs : [])
|
||||
.map((spec) => ({
|
||||
name: String(spec?.name || '').trim(),
|
||||
value: String(spec?.value || '').trim(),
|
||||
}))
|
||||
.filter((spec) => spec.name && spec.value)
|
||||
.slice(0, 4);
|
||||
|
||||
export const formatDropshipSpecs = (specs) => normalizeSpecs(specs)
|
||||
.map((spec) => `${spec.name}:${spec.value}`)
|
||||
.join(' / ');
|
||||
|
||||
export const createDropshipRows = (detail, userDefaults = null, options = {}) => {
|
||||
const product = detail?.product && typeof detail.product === 'object'
|
||||
? detail.product
|
||||
: detail;
|
||||
const productId = String(product?.productId || detail?.productId || '').trim();
|
||||
const defaults = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(userDefaults || {}) };
|
||||
const sourceSkus = Array.isArray(product?.skus) && product.skus.length > 0
|
||||
? product.skus
|
||||
: (Array.isArray(product?.baseSkuRows) ? product.baseSkuRows : []);
|
||||
|
||||
return sourceSkus.map((sku, index) => {
|
||||
const specs = normalizeSpecs(sku?.specs);
|
||||
const cost = toFiniteNumber(sku?.price);
|
||||
const shipping = toFiniteNumber(sku?.freight);
|
||||
const skuIndex = Number(sku?.index) || index + 1;
|
||||
const sourceSkuCode = String(
|
||||
sku?.skuCode || sku?.skuId || sku?.id || `${productId}-SKU-${index + 1}`
|
||||
).trim();
|
||||
return {
|
||||
id: `1688:${productId}:${skuIndex}:${index}`,
|
||||
skuCode: options.useSourceSkuCode ? sourceSkuCode : String(skuIndex),
|
||||
skuIndex,
|
||||
name: String(sku?.name || sku?.selectedText || `SKU ${skuIndex}`).trim(),
|
||||
specs,
|
||||
specsText: formatDropshipSpecs(specs),
|
||||
imageUrl: String(sku?.image || product?.mainImage || detail?.mainImage || '').trim(),
|
||||
available: sku?.available !== false,
|
||||
stock: toFiniteNumber(sku?.stock),
|
||||
stockUnit: String(sku?.stockUnit || '').trim(),
|
||||
quantity: toFiniteNumber(sku?.quantity) ?? 1,
|
||||
cost,
|
||||
price: cost === null || shipping === null
|
||||
? null
|
||||
: Number(((cost + shipping) / 0.55).toFixed(2)),
|
||||
installFee: toFiniteNumber(defaults.installFee),
|
||||
shipping,
|
||||
coupon: toFiniteNumber(defaults.coupon),
|
||||
activityDiscount: toFiniteNumber(defaults.activityDiscount),
|
||||
targetMargin: toFiniteNumber(defaults.targetMargin),
|
||||
...DROPSHIP_FIXED_RATES,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const mergeDropshipSelectionPricingRows = (sourceRows, selectionRows) => {
|
||||
const savedRows = Array.isArray(selectionRows) ? selectionRows : [];
|
||||
const savedBySku = new Map(savedRows.map((row) => [
|
||||
String(row?.skuCode || '').trim().toUpperCase(),
|
||||
row,
|
||||
]));
|
||||
return sourceRows.map((row) => {
|
||||
const saved = savedBySku.get(String(row.skuCode || '').trim().toUpperCase());
|
||||
if (!saved) return row;
|
||||
return {
|
||||
...row,
|
||||
skuCode: String(saved.skuCode || row.skuCode),
|
||||
name: saved.selectedSkuName || saved.originalSkuName || row.name,
|
||||
imageUrl: saved.skuImageUrl || row.imageUrl,
|
||||
cost: saved.cost ?? row.cost,
|
||||
price: saved.finalPrice ?? row.price,
|
||||
installFee: saved.installationFee ?? row.installFee,
|
||||
shipping: saved.freightNational ?? row.shipping,
|
||||
coupon: saved.discountAmount ?? row.coupon,
|
||||
activityDiscount: saved.activityDiscount ?? row.activityDiscount,
|
||||
commission: saved.platformCommissionRatio ?? row.commission,
|
||||
adFee: saved.marketingAdRatio ?? row.adFee,
|
||||
seeding: saved.farmingRatio ?? row.seeding,
|
||||
afterSale: saved.aftersaleRatio ?? row.afterSale,
|
||||
insurance: saved.insuranceRatio ?? row.insurance,
|
||||
brandFee: saved.brandUsageRatio ?? row.brandFee,
|
||||
targetMargin: saved.operatingProfitRatio ?? row.targetMargin,
|
||||
originalSkuName: saved.originalSkuName || row.name,
|
||||
selectedSkuName: saved.selectedSkuName || saved.originalSkuName || row.name,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getDropshipDimensions = (detail, rows = []) => {
|
||||
const product = detail?.product && typeof detail.product === 'object'
|
||||
? detail.product
|
||||
: detail;
|
||||
const declared = (Array.isArray(product?.skuDimensions) ? product.skuDimensions : [])
|
||||
.map((dimension) => String(dimension?.name || '').trim())
|
||||
.filter(Boolean);
|
||||
const discovered = rows.flatMap((row) => (Array.isArray(row.specs) ? row.specs : []))
|
||||
.map((spec) => String(spec?.name || '').trim())
|
||||
.filter(Boolean);
|
||||
return [...new Set([...declared, ...discovered])].slice(0, 4);
|
||||
};
|
||||
|
||||
export const getDropshipSpecValue = (row, dimensionName) => (
|
||||
(Array.isArray(row?.specs) ? row.specs : [])
|
||||
.find((spec) => spec.name === dimensionName)?.value || ''
|
||||
);
|
||||
|
||||
export const applyDropshipDefaultsToRows = (rows, previousDefaults, nextDefaults) => {
|
||||
const previous = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(previousDefaults || {}) };
|
||||
const next = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(nextDefaults || {}) };
|
||||
const keys = ['coupon', 'activityDiscount', 'installFee', 'targetMargin'];
|
||||
return rows.map((row) => {
|
||||
const patch = {};
|
||||
keys.forEach((key) => {
|
||||
const current = toFiniteNumber(row[key]);
|
||||
if (current !== null
|
||||
&& Math.abs(current - previous[key]) < 1e-9
|
||||
&& Math.abs(next[key] - previous[key]) >= 1e-9) {
|
||||
patch[key] = next[key];
|
||||
}
|
||||
});
|
||||
return Object.keys(patch).length ? { ...row, ...patch } : row;
|
||||
});
|
||||
};
|
||||
72
frontend/src/pages/PricingCalculator/dropshipFieldConfig.js
Normal file
72
frontend/src/pages/PricingCalculator/dropshipFieldConfig.js
Normal file
@ -0,0 +1,72 @@
|
||||
const STORAGE_KEY = 'pricingCalcDropshipFieldConfig.v1';
|
||||
|
||||
export const DROPSHIP_DEFAULT_FIELD_ORDER = [
|
||||
'imageUrl', 'specsText', 'cost', 'price',
|
||||
'installFee', 'shipping', 'marginRate', 'commission', 'promoRate', 'adFee',
|
||||
'seeding', 'afterSale', 'insurance', 'brandFee', 'targetMargin', 'profitAmount',
|
||||
'coupon', 'activityDiscount', 'pddPrice', 'pddFinalPrice',
|
||||
];
|
||||
|
||||
export const DROPSHIP_FIELD_LABELS = {
|
||||
imageUrl: '图片',
|
||||
specsText: '规格组合',
|
||||
cost: '采购成本',
|
||||
price: '到手价',
|
||||
installFee: '安装费',
|
||||
shipping: '运费',
|
||||
marginRate: '毛利率',
|
||||
commission: '平台佣金',
|
||||
promoRate: '推广占比',
|
||||
adFee: '营销广告费',
|
||||
seeding: '种菜占比',
|
||||
afterSale: '售后费用',
|
||||
insurance: '保险费',
|
||||
brandFee: '品牌使用费',
|
||||
targetMargin: '营业利润率',
|
||||
profitAmount: '营业利润额',
|
||||
coupon: '优惠券金额',
|
||||
activityDiscount: '活动折扣',
|
||||
pddPrice: '拼单价',
|
||||
pddFinalPrice: '最终拼单价',
|
||||
};
|
||||
|
||||
export const createDefaultDropshipFieldConfig = () => ({
|
||||
order: [...DROPSHIP_DEFAULT_FIELD_ORDER],
|
||||
hidden: [],
|
||||
});
|
||||
|
||||
export const normalizeDropshipFieldConfig = (raw) => {
|
||||
if (!raw || !Array.isArray(raw.order)) return createDefaultDropshipFieldConfig();
|
||||
const order = [...new Set(raw.order.filter((key) => DROPSHIP_DEFAULT_FIELD_ORDER.includes(key)))];
|
||||
DROPSHIP_DEFAULT_FIELD_ORDER.forEach((key, defaultIndex) => {
|
||||
if (order.includes(key)) return;
|
||||
const previous = DROPSHIP_DEFAULT_FIELD_ORDER
|
||||
.slice(0, defaultIndex)
|
||||
.reverse()
|
||||
.find((candidate) => order.includes(candidate));
|
||||
if (previous) {
|
||||
order.splice(order.indexOf(previous) + 1, 0, key);
|
||||
} else {
|
||||
order.push(key);
|
||||
}
|
||||
});
|
||||
const hidden = (Array.isArray(raw.hidden) ? raw.hidden : [])
|
||||
.filter((key) => DROPSHIP_DEFAULT_FIELD_ORDER.includes(key));
|
||||
return { order, hidden };
|
||||
};
|
||||
|
||||
export const loadDropshipFieldConfig = () => {
|
||||
try {
|
||||
return normalizeDropshipFieldConfig(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || 'null'));
|
||||
} catch {
|
||||
return createDefaultDropshipFieldConfig();
|
||||
}
|
||||
};
|
||||
|
||||
export const saveDropshipFieldConfig = (config) => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
// localStorage 不可用时仅保留当前会话状态。
|
||||
}
|
||||
};
|
||||
93
frontend/src/pages/PricingCalculator/fieldConfig.js
Normal file
93
frontend/src/pages/PricingCalculator/fieldConfig.js
Normal file
@ -0,0 +1,93 @@
|
||||
// 字段配置:测算表列的显隐与顺序,持久化到 localStorage(本机偏好,跟随浏览器)
|
||||
const STORAGE_KEY = 'pricingCalcFieldConfig.v1';
|
||||
|
||||
// 全部列均可配置(显隐 + 排序);条码曾是固定首列,向后兼容时特殊处理
|
||||
export const DEFAULT_FIELD_ORDER = [
|
||||
'barcode', 'imageUrl', 'skuStatus', 'stock', 'size', 'color', 'brand', 'cost', 'companyPriceLimit',
|
||||
'price', 'installFee', 'shipping', 'marginRate', 'commission', 'promoRate',
|
||||
'adFee', 'seeding', 'afterSale', 'insurance', 'brandFee', 'targetMargin',
|
||||
'profitAmount', 'coupon', 'activityDiscount', 'pddPrice', 'pddFinalPrice',
|
||||
];
|
||||
|
||||
export const FIELD_LABELS = {
|
||||
barcode: '条码',
|
||||
imageUrl: '图片',
|
||||
skuStatus: '条码状态',
|
||||
stock: '库存',
|
||||
size: '尺寸',
|
||||
color: '颜色',
|
||||
brand: '品牌名称',
|
||||
cost: '成本',
|
||||
companyPriceLimit: '公司限价',
|
||||
price: '到手价',
|
||||
installFee: '安装费',
|
||||
shipping: '运费',
|
||||
marginRate: '毛利率',
|
||||
commission: '平台佣金',
|
||||
promoRate: '推广占比',
|
||||
adFee: '营销广告费',
|
||||
seeding: '种菜占比',
|
||||
afterSale: '售后费用',
|
||||
insurance: '保险费',
|
||||
brandFee: '品牌使用费',
|
||||
targetMargin: '营业利润率',
|
||||
profitAmount: '营业利润额',
|
||||
coupon: '优惠券金额',
|
||||
activityDiscount: '活动折扣',
|
||||
pddPrice: '拼单价',
|
||||
pddFinalPrice: '最终拼单价',
|
||||
};
|
||||
|
||||
// 页面上以百分比展示的字段:导出 xlsx 时保持数值、设置百分比数字格式(0.06 显示为 6.00%)
|
||||
export const PERCENT_FIELD_KEYS = new Set([
|
||||
'marginRate', 'commission', 'promoRate', 'adFee', 'seeding',
|
||||
'afterSale', 'insurance', 'brandFee', 'targetMargin',
|
||||
]);
|
||||
|
||||
// 合并已保存配置与默认配置:剔除已下线字段;新增字段插入默认相邻位置,
|
||||
// 同时保留用户已经调整过的现有字段顺序。
|
||||
export const normalizeFieldConfig = (raw) => {
|
||||
const saved = raw && Array.isArray(raw.order) ? raw : null;
|
||||
if (!saved) return createDefaultFieldConfig();
|
||||
|
||||
const order = [...new Set(saved.order.filter((key) => DEFAULT_FIELD_ORDER.includes(key)))];
|
||||
DEFAULT_FIELD_ORDER.forEach((key, defaultIndex) => {
|
||||
if (order.includes(key)) return;
|
||||
const previousKey = DEFAULT_FIELD_ORDER
|
||||
.slice(0, defaultIndex)
|
||||
.reverse()
|
||||
.find((candidate) => order.includes(candidate));
|
||||
if (previousKey) {
|
||||
order.splice(order.indexOf(previousKey) + 1, 0, key);
|
||||
return;
|
||||
}
|
||||
const nextKey = DEFAULT_FIELD_ORDER
|
||||
.slice(defaultIndex + 1)
|
||||
.find((candidate) => order.includes(candidate));
|
||||
order.splice(nextKey ? order.indexOf(nextKey) : order.length, 0, key);
|
||||
});
|
||||
const hidden = (saved && Array.isArray(saved.hidden) ? saved.hidden : [])
|
||||
.filter((key) => DEFAULT_FIELD_ORDER.includes(key));
|
||||
return { order, hidden };
|
||||
};
|
||||
|
||||
export const createDefaultFieldConfig = () => ({
|
||||
order: [...DEFAULT_FIELD_ORDER],
|
||||
hidden: [],
|
||||
});
|
||||
|
||||
export const loadFieldConfig = () => {
|
||||
try {
|
||||
return normalizeFieldConfig(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || 'null'));
|
||||
} catch {
|
||||
return createDefaultFieldConfig();
|
||||
}
|
||||
};
|
||||
|
||||
export const saveFieldConfig = (config) => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
// 隐私模式等写入失败时静默降级,仅本次会话生效
|
||||
}
|
||||
};
|
||||
1329
frontend/src/pages/PricingCalculator/index.jsx
Normal file
1329
frontend/src/pages/PricingCalculator/index.jsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user