Compare commits

...

20 Commits

Author SHA1 Message Date
8448f7a0e8 Merge pull request 'feat: support product information PostgreSQL config' (#5) from codex/add-product-information-postgres into main 2026-09-12 21:34:32 +08:00
lunanting
4b1e004b3c feat: support product information PostgreSQL config 2026-09-12 21:34:07 +08:00
d726dfd4cb Merge pull request 'docs: require database load and index validation' (#4) from codex/database-index-guidelines into main 2026-09-12 19:49:57 +08:00
lunanting
3c1895a9e2 docs: require database load and index validation 2026-09-12 19:48:57 +08:00
490c68f7d7 Merge pull request #3: remove commerce monitoring feature suites
Validated frontend, backend, remote-access and dashboard route checks.
2026-09-12 18:51:35 +08:00
lunanting
d9ebbba61e refactor: remove commerce monitoring feature suites 2026-09-12 18:49:37 +08:00
lunanting
36948c98ca refactor: remove store and operations feature suites 2026-09-12 17:50:26 +08:00
lunanting
1d87c3f94c docs: define safe production deployment workflow 2026-09-12 17:38:11 +08:00
lunanting
4f78a5b512 security: run managed Redis unprivileged 2026-09-12 17:33:26 +08:00
lunanting
e1772917ea fix: persist provisioned Redis ACL users 2026-09-12 17:29:24 +08:00
lunanting
4f48a20670 fix: avoid Redis INFO in restricted clients 2026-09-12 17:26:23 +08:00
lunanting
c4f14b0cdf fix: label Redis certificate mount for SELinux 2026-09-12 17:21:05 +08:00
lunanting
8c448bcd74 feat: provision managed Redis during enrollment 2026-09-12 17:13:59 +08:00
lunanting
86ab958911 security: restrict bootstrap state permissions 2026-09-12 16:58:00 +08:00
lunanting
3229c2a1c3 fix: use backend liveness health check 2026-09-12 16:55:48 +08:00
lunanting
a85acabde5 ops: add isolated production deployment 2026-09-12 16:53:18 +08:00
lunanting
088a24c3a9 fix: route bootstrap HTTPS through port 8443 2026-09-12 16:11:05 +08:00
lunanting
5689671312 fix: use DNS validation for managed TLS certificate 2026-09-12 16:08:57 +08:00
lunanting
584e0b4612 docs: define safe multi-user and AI contribution workflow 2026-09-12 16:06:04 +08:00
lunanting
3239d6a558 feat: bootstrap local instances from central system database 2026-09-12 16:06:01 +08:00
140 changed files with 2978 additions and 30028 deletions

2
.gitignore vendored
View File

@ -54,5 +54,7 @@ tmall-board-speed-check.json
**/.env.*
!**/.env.example
**/uploads/
.remote-access/
.remote-access.backup-*/
.workbuddy/
**/node_modules

15
AGENTS.md Normal file
View File

@ -0,0 +1,15 @@
# AI 开发协作入口
开始修改、提交、推送或合并前,必须阅读并遵守 [仓库提交与合并规范](./仓库提交与合并规范.md)。准备更新生产服务器时,还必须阅读并遵守 [服务器部署规范](./服务器部署规范.md)。这些文件是本仓库多人协作的具体流程,不可仅凭本摘要跳过全文。
- 每个任务使用独立目录和唯一任务分支,默认前缀 `codex/`;不得共享目录并行编辑。
- 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。
- 推送前同步远端,逐项检查最终差异并验证;被拒绝后先查明并合并更新,禁止任何形式的强推。
- 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;独立审核与最新内容的验证通过后由维护者合并。
- 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。
- 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。
- 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。
- 最终交付报告实际分支、提交 SHA、验证结果及 PR 状态,不能将“已推送”报告为“已合并”。
这些文档不代表服务端保护已启用。任何 AI 工具无法自动读取规则时,由使用它的开发者主动提供全文。

View File

@ -2,33 +2,21 @@
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、店铺体检、客服质检、上架测算、AI 买家秀和客户端管理等现有模块。部分菜单仍沿用原项目的占位页面。
保留仪表盘、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
已删除市场数据整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
## 本地启动
需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。
需要 Node.js 22。团队开发机器无需安装或初始化 MySQL、Redis 及第三方数据库;使用管理员发放的部署 Key,一次注册后自动连接中央系统数据库、公网 TLS Redis 和第三方数据库。完整步骤及安全边界见 [远程系统数据库部署](./remote-access/README.md)。
```bash
npm --prefix backend ci
npm --prefix frontend ci
cp backend/.env.example backend/.env
npm run remote:dev
```
填写 `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` 覆盖。首次启动按照安装与设置向导初始化系统。
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
## 验证与构建
@ -39,8 +27,10 @@ npm --prefix frontend run check
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。
## 参与修改
开始开发前请阅读 [仓库提交与合并规范(多用户与 AI 开发必读)](./仓库提交与合并规范.md)。准备更新生产服务器时必须继续阅读 [服务器部署规范(开发者与 AI 必读)](./服务器部署规范.md)。AI 工具的规则入口为 [AGENTS.md](./AGENTS.md)。每个任务使用独立目录和分支,通过 PR、同步验证和独立审核合并,禁止强推或覆盖其他开发者的工作。
所有用户均可 Fork 仓库、创建分支并提交 Pull Request。直接推送本仓库需要仓库所有者授予协作者写权限。提交时请说明修改和验证结果,勿提交 `.env`、密码、令牌、数据库备份或真实业务数据。

5
backend/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.env
npm-debug.log*
coverage
runtime

View File

@ -116,8 +116,11 @@ 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

View File

@ -0,0 +1,16 @@
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"]

View File

@ -28,6 +28,7 @@
"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",

View File

@ -9,8 +9,7 @@
"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",
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
"alert:check": "node src/services/alertChecker.js"
},
"dependencies": {
"@aws-sdk/client-s3": "3.975.0",
@ -32,6 +31,7 @@
"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",

View File

@ -1,404 +0,0 @@
#!/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;
});

View File

@ -1,218 +0,0 @@
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,
};

View File

@ -1,55 +0,0 @@
-- 商品监控配套:部门商品标签规则 + 部门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负责人配置';

View File

@ -1,14 +1,14 @@
const { isRetiredSystemConfigKey, pruneRetiredSidebarConfig } = require('../services/retiredFeatures');
const {
isRetiredDatabaseConfigName,
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,17 +25,7 @@ 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,
@ -108,10 +98,6 @@ const {
testSelectionFissionAiConfig,
} = require('../services/selectionFissionAiSettings');
const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService');
const {
getProductTagConfig,
saveProductTagConfig,
} = require('../services/productTagConfigService');
const backfillJobs = new Map();
const productCodeImportUpload = multer({
@ -129,44 +115,6 @@ 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;
@ -208,93 +156,6 @@ 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({
@ -1350,40 +1211,6 @@ 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 专用以图搜图 ====================
@ -1622,12 +1449,10 @@ 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) {
@ -1640,6 +1465,11 @@ function normalizeDatabaseConfigName(rawName) {
error.statusCode = 400;
throw error;
}
if (isRetiredDatabaseConfigName(configName)) {
const error = new Error('该功能已下线,不能创建对应数据库配置');
error.statusCode = 410;
throw error;
}
return configName;
}
@ -1649,6 +1479,13 @@ 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(
@ -1787,7 +1624,9 @@ router.put('/database', async (req, res) => {
}
// 根据数据库类型设置默认端口
const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql');
const resolvedDriver = normalizeDatabaseDriver(
driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql')
);
const defaultPort = getDefaultPortByDriver(resolvedDriver);
const configData = {
@ -1881,6 +1720,8 @@ 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');
@ -1900,6 +1741,26 @@ 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');

View File

@ -1,397 +0,0 @@
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;

View File

@ -1,93 +0,0 @@
'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;

View File

@ -0,0 +1,364 @@
'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;

View File

@ -1,18 +1,6 @@
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,
@ -24,23 +12,6 @@ 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;
}
@ -164,68 +135,4 @@ 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;

View File

@ -5,6 +5,11 @@ const {
shouldAutoRestartAfterInstall,
testSystemDatabaseBootstrapConnection,
} = require('../services/installService');
const {
DEFAULT_BOOTSTRAP_ENDPOINT,
enrollRemoteInstallation,
scheduleRemoteRestart,
} = require('../services/remoteEnrollmentService');
const router = express.Router();
@ -18,6 +23,47 @@ 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();

View File

@ -1,268 +0,0 @@
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;

View File

@ -1,20 +1,9 @@
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.
@ -40,43 +29,4 @@ 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;

View File

@ -1,833 +0,0 @@
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,
};

View File

@ -1,73 +0,0 @@
'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;

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +0,0 @@
'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;

View File

@ -1,417 +0,0 @@
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;

View File

@ -1,999 +0,0 @@
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;

View File

@ -41,13 +41,15 @@ function buildRedisClientConfig(config) {
return {
host: config.host,
port: parseInt(config.port || '6379', 10),
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
username: config.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,
};
}
@ -61,8 +63,10 @@ function getConfigSignature(config) {
host: config.host,
port: parseInt(config.port || '6379', 10),
db: parseInt(config.db || '0', 10),
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
username: config.username || process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
password: config.password || '',
tls: Boolean(config.tls),
tlsServerName: config.tlsServerName || '',
});
}

View File

@ -14,26 +14,12 @@ 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',

View File

@ -19,7 +19,6 @@ 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');
@ -30,14 +29,7 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
const channelRoutes = require('./api/channel');
const analysisRoutes = require('./api/analysis');
const productCodeBoardRoutes = require('./api/productCodeBoard');
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 dashboardRoutes = require('./api/dashboard');
const database = require('./config/database');
const cloudDb = require('./config/cloudDatabase');
const jianSunDb = require('./config/jianSunDatabase');
@ -48,8 +40,6 @@ 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');
@ -133,7 +123,6 @@ 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;
@ -636,7 +625,6 @@ 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);
@ -652,16 +640,9 @@ 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/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/dashboard', authMiddleware, dashboardRoutes);
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
// =====================
// 数据分析 API(公开访问,可根据需要添加认证)
@ -727,8 +708,6 @@ async function startServer(options = {}) {
startPddSkuDimensionSnapshotSchedulers();
startPlanningMayProductSnapshotSchedulers();
startPlanningDingWorkflowSyncSchedulers();
startOpsBIncentiveSnapshotSchedulers();
startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler();
// Keep the first selection-pool page request from paying the remote schema

View File

@ -1,164 +0,0 @@
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 };

View File

@ -1,31 +0,0 @@
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 };

View File

@ -1,375 +0,0 @@
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,
};

View File

@ -1,237 +0,0 @@
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,
};

File diff suppressed because it is too large Load Diff

View File

@ -1,111 +0,0 @@
'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,
};

File diff suppressed because it is too large Load Diff

View File

@ -1,254 +0,0 @@
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,
};

View File

@ -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 || allowed.has('market_insight') || canPlan;
const canUseMarket = isAdmin || canPlan;
return {
controlCenter: true,
@ -77,10 +77,7 @@ 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'),
};
}

View File

@ -3,16 +3,10 @@ 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'"],
@ -244,47 +238,10 @@ 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',
@ -920,11 +877,7 @@ 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();

View File

@ -1,430 +0,0 @@
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 };

View File

@ -1,943 +0,0 @@
'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,
};

View File

@ -1,521 +0,0 @@
'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,
};

View File

@ -54,8 +54,11 @@ 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';
@ -86,6 +89,12 @@ 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) {
@ -189,7 +198,10 @@ 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,
@ -203,8 +215,11 @@ 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),
@ -354,12 +369,15 @@ 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,
});
@ -390,8 +408,11 @@ 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,
};
}
@ -400,8 +421,11 @@ 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,
};
}
@ -411,8 +435,11 @@ 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,
};
}

View File

@ -0,0 +1,67 @@
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,
};

View File

@ -1,14 +1,36 @@
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_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_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;
@ -28,6 +50,18 @@ 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;
@ -36,4 +70,12 @@ async function cleanupRetiredFeatureSettings(db) {
}
}
module.exports = { RETIRED_PAGE_KEYS, RETIRED_CONFIG_KEYS, isRetiredSystemConfigKey, pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings };
module.exports = {
RETIRED_PAGE_KEYS,
RETIRED_CONFIG_KEYS,
RETIRED_DATABASE_CONFIG_NAMES,
isRetiredSystemConfigKey,
isRetiredDatabaseConfigName,
pruneRetiredSidebarConfig,
cleanupRetiredFeatureSettings,
};

View File

@ -1,255 +0,0 @@
'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');
});

View File

@ -0,0 +1,54 @@
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));
}
});

View File

@ -1,60 +0,0 @@
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');
});

View File

@ -92,12 +92,9 @@ 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: [] })).promotion, true);
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).productTest, true);
});
test('SSO ticket is consumed exactly once and produces a normal web token', async () => {

View File

@ -5,7 +5,6 @@ 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,
@ -38,40 +37,6 @@ 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';
@ -178,20 +143,3 @@ 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, '陆南亭');
});

View File

@ -1,110 +0,0 @@
'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'] },
],
});
});

View File

@ -0,0 +1,17 @@
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);
});

View File

@ -1,14 +1,31 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings, isRetiredSystemConfigKey } = require('../src/services/retiredFeatures');
const {
pruneRetiredSidebarConfig,
cleanupRetiredFeatureSettings,
isRetiredDatabaseConfigName,
isRetiredSystemConfigKey,
} = require('../src/services/retiredFeatures');
test('retired sidebar cleanup is recursive and idempotent', () => {
const saved = JSON.stringify({ entries: [{ key: 'agent' }, { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'settings', label: '管理设置' }] }] });
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 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);
});
@ -16,11 +33,12 @@ 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"}]}' }] : [];
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"},{"key":"settings"}]}' }] : [];
} });
const deletes = calls.filter((call) => call.sql.startsWith('DELETE'));
assert.equal(deletes.length, 4);
assert.equal(deletes.length, 5);
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: 'product-analysis' }]);
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'settings' }]);
});

View File

@ -3,14 +3,11 @@ const assert = require('node:assert/strict');
const Module = require('node:module');
const express = require('express');
test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => {
test('retired analysis endpoints return 404 while retained selection endpoint works', 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 };
}
@ -24,10 +21,10 @@ test('retired market endpoints return 404 while retained incentive and calculato
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']) {
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']) {
assert.equal((await fetch(base + path)).status, 404, path);
}
for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) {
for (const path of ['/selection-fission/1688-products/123']) {
const response = await fetch(base + path);
assert.equal(response.status, 200, path);
assert.equal((await response.json()).success, true);

View File

@ -0,0 +1,16 @@
# 生产部署(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 网关。

View File

@ -0,0 +1,117 @@
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

View File

@ -0,0 +1,16 @@
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

View File

@ -0,0 +1,69 @@
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;
}
}

View File

@ -143,18 +143,6 @@ 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');
};

View File

@ -1,74 +0,0 @@
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 })
);

View File

@ -1,44 +0,0 @@
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 })
);

View File

@ -0,0 +1,16 @@
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 })
);

View File

@ -1,34 +0,0 @@
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,
});
};

View File

@ -4,6 +4,14 @@ 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);
};

View File

@ -1,17 +0,0 @@
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')
);

View File

@ -2,21 +2,6 @@ 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: {

View File

@ -1,79 +0,0 @@
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)
);

View File

@ -1,42 +0,0 @@
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 })
);

View File

@ -1,34 +0,0 @@
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 }
);

View File

@ -1,17 +0,0 @@
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')
);

View File

@ -1,23 +0,0 @@
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 })
);

View File

@ -2,45 +2,12 @@ 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 },
],
@ -63,28 +30,6 @@ 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();
@ -100,18 +45,6 @@ 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);
@ -148,15 +81,10 @@ 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, [...nextEntries, ...promotedNotDuplicated]),
entries: mergeSidebarEntries(defaults.entries, config?.entries || []),
};
};

View File

@ -2,33 +2,25 @@ 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';
@ -64,49 +56,24 @@ 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' },
};

File diff suppressed because it is too large Load Diff

View File

@ -1,688 +0,0 @@
.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));
}
}

View File

@ -1,378 +0,0 @@
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;

View File

@ -1,160 +0,0 @@
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;

View File

@ -1,516 +0,0 @@
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;

View File

@ -1,634 +0,0 @@
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;

View File

@ -1,872 +0,0 @@
.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;
}
}

View File

@ -1,105 +0,0 @@
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;

View File

@ -1,112 +0,0 @@
.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%;
}
}

View File

@ -1,261 +0,0 @@
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;

View File

@ -1,299 +0,0 @@
.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%;
}
}

View File

@ -1,18 +1,15 @@
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';
@ -27,12 +24,7 @@ import {
SycmSimpleTable,
SycmSparkline,
} from '../../components/Common';
import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis';
import {
getStoreCheckupHomeCompensation,
getStoreCheckupHomeReviews,
getStoreCheckupHomeSummary,
} from '../../api/storeCheckup';
import { getDashboardMeta, getDashboardSummary } from '../../api/dashboard';
import './stylePreview.css';
const overviewMetrics = [
@ -166,256 +158,7 @@ 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);
@ -431,27 +174,13 @@ 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;
getProductAnalysisMeta().then((response) => {
getDashboardMeta().then((response) => {
if (!active) return;
const shops = Array.isArray(response?.data?.shops) ? response.data.shops : [];
setShopOptions([
@ -473,7 +202,7 @@ const DevelopmentStylePreview = () => {
const params = customStartDate && customEndDate
? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined }
: { period: PERIOD_MAP[activePeriod] || 'week', shopName: shopName || undefined };
getDevelopmentDashboard(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
getDashboardSummary(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
const payload = response?.data || {};
setDashboard(payload);
}).catch((error) => {
@ -486,74 +215,9 @@ 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'));
@ -575,10 +239,6 @@ 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('');
@ -591,12 +251,6 @@ const DevelopmentStylePreview = () => {
setShopName(value || '');
setDashboardLoading(true);
setDashboardError('');
setStoreCheckupLoading(true);
setStoreCheckupError('');
setReviewLoading(true);
setReviewError('');
setCompensationLoading(true);
setCompensationError('');
};
const displayedDateRange = customStartDate && customEndDate
? [dayjs(customStartDate), dayjs(customEndDate)]
@ -668,15 +322,6 @@ 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);
@ -795,28 +440,8 @@ 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>
@ -917,53 +542,6 @@ 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

View File

@ -6,6 +6,7 @@ import {
Form,
Input,
InputNumber,
Modal,
Result,
Row,
Space,
@ -25,6 +26,7 @@ import {
import { useNavigate } from 'react-router-dom';
import {
bootstrapInstallation,
bootstrapRemoteInstallation,
getInstallStatus,
testInstallSystemDatabase,
} from '../../api/install';
@ -40,6 +42,7 @@ 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);
@ -48,6 +51,9 @@ 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) {
@ -187,6 +193,27 @@ 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>
@ -239,6 +266,98 @@ 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="首次安装"

View File

@ -1,492 +0,0 @@
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;

View File

@ -1,224 +0,0 @@
.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;
}
}

View File

@ -1,233 +0,0 @@
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;

View File

@ -1,160 +0,0 @@
.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; }
}

View File

@ -1,75 +0,0 @@
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;

View File

@ -1,909 +0,0 @@
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;

View File

@ -1,90 +0,0 @@
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;

View File

@ -1,177 +0,0 @@
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;

View File

@ -1,80 +0,0 @@
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())}`;
};

View File

@ -1,136 +0,0 @@
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,
};
};

View File

@ -1,176 +0,0 @@
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;
});
};

View File

@ -1,72 +0,0 @@
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 不可用时仅保留当前会话状态。
}
};

View File

@ -1,93 +0,0 @@
// 字段配置:测算表列的显隐与顺序,持久化到 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 {
// 隐私模式等写入失败时静默降级,仅本次会话生效
}
};

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