Merge PR #33: 每日进销存增加定时截图通知
经仓库所有者 youruo 明确授权合并(2026-09-24 授权),授权覆盖最终提交 3bfc48b8b36a22ab422a6566c1637fc7d5f1c42a
This commit is contained in:
commit
daf3646470
@ -3,12 +3,17 @@ FROM node:22-bookworm-slim
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
# 每日进销存定时截图需要 Chromium 与中文字体:
|
||||
# · fonts-noto-cjk —— 否则截图里所有中文都会变成方块
|
||||
# · playwright install --with-deps chromium —— 默认镜像不带浏览器
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
RUN npm ci --omit=dev --no-audit --no-fund \
|
||||
&& npx playwright install --with-deps chromium \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.npm/_cacache
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
81
backend/src/api/dailyInventoryNotification.js
Normal file
81
backend/src/api/dailyInventoryNotification.js
Normal file
@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 定时截图通知(管理员专用)
|
||||
//
|
||||
// 安全约定:
|
||||
// · 全部接口要求登录且 role === 'admin'
|
||||
// · 响应里只有「是否已配置」与脱敏预览,绝不返回通知中心密钥 / 对象存储密钥
|
||||
// · 收件人名单由后端校验与规范化,前端只能提交姓名文本
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getDailyInventoryNotificationAdminConfig,
|
||||
listRunRecords,
|
||||
saveDailyInventoryNotificationConfig,
|
||||
} = require('../services/dailyInventoryNotificationSettings');
|
||||
const {
|
||||
runDailyInventoryNotificationJob,
|
||||
} = require('../jobs/dailyInventoryNotificationJob');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[DailyInventoryNotification]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
/** 仅管理员可查看与修改通知设置、执行立即测试 */
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req?.user?.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: '仅管理员可操作通知设置' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
router.use(requireAdmin);
|
||||
|
||||
// 读取通知设置(含通知中心 / 对象存储的配置状态与最近执行记录)
|
||||
router.get('/config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getDailyInventoryNotificationAdminConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载每日进销存通知设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存通知设置
|
||||
router.put('/config', async (req, res) => {
|
||||
try {
|
||||
const saved = await saveDailyInventoryNotificationConfig(req.body || {});
|
||||
res.json({ success: true, data: { ...saved, admin: await getDailyInventoryNotificationAdminConfig() } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存每日进销存通知设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 立即测试:真实走一次截图 + 上传 + 通知,但不写入正式幂等记录
|
||||
router.post('/test', async (_req, res) => {
|
||||
try {
|
||||
const result = await runDailyInventoryNotificationJob({ mode: 'test' });
|
||||
res.json({ success: result?.success === true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '执行每日进销存通知测试失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 最近执行记录(管理员排障用)
|
||||
router.get('/runs', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 50);
|
||||
res.json({ success: true, data: await listRunRecords(limit) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载每日进销存通知执行记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -36,6 +36,7 @@ const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
|
||||
const mkhBomRoutes = require('./api/mkhBom');
|
||||
const dailyInventoryRoutes = require('./api/dailyInventory');
|
||||
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
|
||||
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
|
||||
const returnMonitorRoutes = require('./api/returnMonitor');
|
||||
const returnStockWarningRoutes = require('./api/returnStockWarning');
|
||||
@ -54,6 +55,9 @@ const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
|
||||
const {
|
||||
startDailyInventorySnapshotScheduler,
|
||||
} = require('./jobs/dailyInventorySnapshotJob');
|
||||
const {
|
||||
startDailyInventoryNotificationScheduler,
|
||||
} = require('./jobs/dailyInventoryNotificationJob');
|
||||
const {
|
||||
startAlibaba1688ImageSearchExpiryScheduler,
|
||||
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
||||
@ -667,6 +671,7 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
|
||||
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
|
||||
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
|
||||
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控
|
||||
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
|
||||
@ -756,6 +761,11 @@ async function startServer(options = {}) {
|
||||
console.log(` [缓存] ${redisTest.message}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// 每日进销存截图通知:与「后台定时任务总开关」解耦,保证即使总开关关闭,
|
||||
// 只要功能启用就仍会按配置时间推送(可用 DAILY_INVENTORY_NOTIFICATION_ENABLED=false 单独关闭)。
|
||||
// 它每分钟只做一次轻量判断,命中配置时间才真正截图上传。
|
||||
startDailyInventoryNotificationScheduler();
|
||||
|
||||
if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') {
|
||||
console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理');
|
||||
return;
|
||||
|
||||
350
backend/src/jobs/dailyInventoryNotificationJob.js
Normal file
350
backend/src/jobs/dailyInventoryNotificationJob.js
Normal file
@ -0,0 +1,350 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 定时截图通知
|
||||
//
|
||||
// 执行流程:
|
||||
// ① 单实例锁(MySQL GET_LOCK),避免多实例重复截图推送
|
||||
// ② 校验:功能开启 / 有收件人 / 通知中心已配置 / 对象存储已配置,任一不满足就记录原因并退出
|
||||
// ③ 按配置时间(默认 01:00 Asia/Shanghai)触发;业务日期 = 北京时间昨日(与页面 T-1 口径一致)
|
||||
// ④ 截图 → 上传对象存储 → 拿到公开 HTTPS 地址后才发通知
|
||||
// ⑤ 逐用户发送,用「业务日期 + 收件人」幂等键去重;单人失败不影响其他人
|
||||
// ⑥ 截图/上传/通知失败均做有限次数重试与指数退避
|
||||
// ⑦ 每次执行都落一条记录(时间 / 截图地址 / 通知对象 / 成功状态 / 失败原因)
|
||||
//
|
||||
// 立即测试(mode='test'):走同一条链路,但幂等键带随机数,**不写入正式幂等记录**,
|
||||
// 因此不会影响下一次正式定时推送。
|
||||
|
||||
const crypto = require('crypto');
|
||||
const cron = require('node-cron');
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
appendRunRecord,
|
||||
buildIdempotencyKey,
|
||||
getDailyInventoryNotificationRuntimeConfig,
|
||||
getObjectStorageStatus,
|
||||
hasNotified,
|
||||
loadDailyInventoryNotificationState,
|
||||
markNotified,
|
||||
normalizeRecipients,
|
||||
} = require('../services/dailyInventoryNotificationSettings');
|
||||
const {
|
||||
captureAndUploadDailyInventory,
|
||||
chinaTimeParts,
|
||||
resolvePageUrl,
|
||||
} = require('../services/dailyInventoryScreenshot');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
isYydNotificationCenterConfigured,
|
||||
sendYydNotification,
|
||||
} = require('../services/yydNotificationCenterClient');
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const LOCK_KEY = 'daily_inventory_notification_job';
|
||||
const LOCK_WAIT_SECONDS = 2;
|
||||
const NOTIFICATION_TITLE = '每日进销存数据';
|
||||
const ACTION_TITLE = '查看每日进销存';
|
||||
|
||||
// 重试策略:最多 3 次,退避 2s / 4s
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_BASE_DELAY_MS = 2000;
|
||||
// 命中时间后允许补跑的窗口与次数上限(防止全天每分钟重试刷屏)
|
||||
const RETRY_WINDOW_MINUTES = 30;
|
||||
const MAX_SCHEDULED_ATTEMPTS_PER_DAY = 3;
|
||||
|
||||
let running = false;
|
||||
let timer = null;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** 有限次数重试 + 指数退避 */
|
||||
async function withRetry(task, { label, attempts = MAX_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS } = {}) {
|
||||
let lastError = null;
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
try {
|
||||
return await task();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const delay = baseDelayMs * (2 ** index);
|
||||
console.error(
|
||||
`[DailyInventoryNotify] ${label} 第 ${index + 1}/${attempts} 次失败:${error.message}`
|
||||
+ `${index < attempts - 1 ? `,${delay}ms 后重试` : ''}`
|
||||
);
|
||||
if (index < attempts - 1) await sleep(delay);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/** 业务日期 = 北京时间昨日(页面标注 T-1,凌晨跑批产出昨日快照) */
|
||||
function resolveBizDate(now = new Date()) {
|
||||
const target = now instanceof Date ? now : new Date(now);
|
||||
const shifted = new Date(target.getTime() - 24 * 60 * 60 * 1000);
|
||||
return chinaTimeParts(shifted).bizDate;
|
||||
}
|
||||
|
||||
function beijingTimeText(date = new Date()) {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(target);
|
||||
}
|
||||
|
||||
function currentBeijingHhMm(now = new Date()) {
|
||||
const text = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(now instanceof Date ? now : new Date(now));
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 全实例锁:拿到锁才继续执行,拿不到说明其它实例正在跑 */
|
||||
async function withJobLock(runner) {
|
||||
const pool = database.local.pool;
|
||||
let connection = null;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [LOCK_KEY, LOCK_WAIT_SECONDS]);
|
||||
const acquired = Number(rows?.[0]?.lockStatus || 0) === 1;
|
||||
if (!acquired) {
|
||||
return { success: false, skipped: true, reason: 'lock_not_acquired' };
|
||||
}
|
||||
return await runner();
|
||||
} catch (error) {
|
||||
return { success: false, skipped: true, reason: 'lock_error', error: error.message };
|
||||
} finally {
|
||||
if (connection) {
|
||||
try {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [LOCK_KEY]);
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildNotificationContent({ bizDate, takenAt, imageUrl, pageUrl }) {
|
||||
return [
|
||||
`**数据日期:** ${bizDate}`,
|
||||
`**截图生成时间:** ${takenAt}(${TIMEZONE})`,
|
||||
`**图片地址:** ${imageUrl}`,
|
||||
`**页面地址:** ${pageUrl}`,
|
||||
'',
|
||||
`点击右下角「${ACTION_TITLE}」可直达每日进销存页面。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次(正式 / 测试)
|
||||
* @param {Object} options
|
||||
* mode 'scheduled' | 'manual' | 'test'
|
||||
* force true 时忽略「今天已调度过」的节流判断(手动触发用)
|
||||
* now 指定执行时刻,便于测试
|
||||
*/
|
||||
async function runDailyInventoryNotificationJob(options = {}) {
|
||||
const mode = options.mode || 'scheduled';
|
||||
const now = options.now ? new Date(options.now) : new Date();
|
||||
const startedAt = now.toISOString();
|
||||
|
||||
if (running) {
|
||||
return { success: false, skipped: true, reason: 'already_running' };
|
||||
}
|
||||
running = true;
|
||||
|
||||
try {
|
||||
return await withJobLock(async () => {
|
||||
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
const recipients = normalizeRecipients(config.recipients);
|
||||
const bizDate = resolveBizDate(now);
|
||||
|
||||
const finish = async (payload) => {
|
||||
await appendRunRecord({ ...payload, runAt: startedAt, bizDate, mode, notifyTime: config.notifyTime });
|
||||
return payload;
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
return finish({ success: false, reason: 'disabled', error: '功能未启用' });
|
||||
}
|
||||
if (!recipients.length) {
|
||||
return finish({ success: false, reason: 'no_recipient', error: '未配置通知用户' });
|
||||
}
|
||||
|
||||
let centerConfig = null;
|
||||
try {
|
||||
centerConfig = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
centerConfig = null;
|
||||
}
|
||||
if (!isYydNotificationCenterConfigured(centerConfig)) {
|
||||
return finish({ success: false, reason: 'notification_center_not_configured', error: '统一通知中心未配置' });
|
||||
}
|
||||
|
||||
const storage = await getObjectStorageStatus();
|
||||
if (!storage.configured) {
|
||||
return finish({ success: false, reason: 'object_storage_not_configured', error: '对象存储未配置' });
|
||||
}
|
||||
|
||||
// 截图 → 上传(失败直接结束,绝不发通知)
|
||||
let shot;
|
||||
try {
|
||||
shot = await withRetry(
|
||||
() => captureAndUploadDailyInventory({ now }),
|
||||
{ label: '截图并上传' }
|
||||
);
|
||||
} catch (error) {
|
||||
return finish({ success: false, reason: 'screenshot_failed', error: error.message });
|
||||
}
|
||||
|
||||
const pageUrl = resolvePageUrl();
|
||||
const takenAt = beijingTimeText(now);
|
||||
const nonce = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
const deliveries = [];
|
||||
for (const recipient of recipients) {
|
||||
const idempotencyKey = buildIdempotencyKey({ bizDate, recipient, mode, nonce });
|
||||
// 正式推送先查幂等:同一业务日期 + 同一用户只成功通知一次
|
||||
if (mode !== 'test' && await hasNotified(bizDate, recipient)) {
|
||||
deliveries.push({ recipient, status: 'skipped', reason: 'already_notified' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await withRetry(
|
||||
() => sendYydNotification({
|
||||
idempotencyKey,
|
||||
recipientAlias: recipient,
|
||||
title: NOTIFICATION_TITLE,
|
||||
content: buildNotificationContent({
|
||||
bizDate, takenAt, imageUrl: shot.url, pageUrl,
|
||||
}),
|
||||
actionTitle: ACTION_TITLE,
|
||||
actionUrl: pageUrl,
|
||||
}),
|
||||
{ label: `通知 ${recipient}` }
|
||||
);
|
||||
const deliveryId = response?.id || response?.data?.id || null;
|
||||
if (mode !== 'test') await markNotified(bizDate, recipient, { deliveryId, idempotencyKey });
|
||||
deliveries.push({ recipient, status: 'sent', deliveryId });
|
||||
} catch (error) {
|
||||
// 单人失败不阻断其他人
|
||||
console.error(`[DailyInventoryNotify] 通知 ${recipient} 失败:${error.message}`);
|
||||
deliveries.push({ recipient, status: 'failed', error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
const sentCount = deliveries.filter((d) => d.status === 'sent').length;
|
||||
const failedCount = deliveries.filter((d) => d.status === 'failed').length;
|
||||
const success = sentCount > 0;
|
||||
|
||||
console.log(
|
||||
`[DailyInventoryNotify] ${mode} 完成:业务日 ${bizDate},截图 ${shot.url},`
|
||||
+ `通知成功 ${sentCount} / 失败 ${failedCount} / 跳过 ${deliveries.length - sentCount - failedCount}`
|
||||
);
|
||||
|
||||
return finish({
|
||||
success,
|
||||
reason: success ? null : 'notify_failed',
|
||||
error: success ? null : '所有收件人均通知失败',
|
||||
screenshotUrl: shot.url,
|
||||
screenshotKey: shot.key,
|
||||
rowCount: shot.rowCount,
|
||||
recipients: deliveries,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一业务日期已经发起过的正式调度次数与最后一次结果(用于失败重试节流) */
|
||||
async function scheduledAttemptStatus(bizDate) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const runs = (Array.isArray(state.runs) ? state.runs : [])
|
||||
.filter((r) => r.bizDate === bizDate && r.mode === 'scheduled');
|
||||
return {
|
||||
count: runs.length,
|
||||
lastSuccess: runs.length ? runs[runs.length - 1].success === true : null,
|
||||
};
|
||||
}
|
||||
|
||||
function minutesOfDay(hhmm) {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || ''));
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (hour > 23 || minute > 59) return null;
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否应当发起一次正式调度:
|
||||
* · 恰好命中配置时间 → 执行
|
||||
* · 命中时间之后的 RETRY_WINDOW_MINUTES 分钟内 → 仅在上一次失败且未超出重试上限时补跑
|
||||
* · 其余时间 → 不执行
|
||||
*/
|
||||
async function shouldRunScheduled(config, now) {
|
||||
const target = minutesOfDay(config.notifyTime);
|
||||
const current = minutesOfDay(currentBeijingHhMm(now));
|
||||
if (target === null || current === null) return false;
|
||||
|
||||
const diff = current - target;
|
||||
if (diff < 0 || diff > RETRY_WINDOW_MINUTES) return false;
|
||||
if (diff === 0) return true;
|
||||
|
||||
const status = await scheduledAttemptStatus(resolveBizDate(now));
|
||||
return status.lastSuccess === false && status.count < MAX_SCHEDULED_ATTEMPTS_PER_DAY;
|
||||
}
|
||||
|
||||
function startDailyInventoryNotificationScheduler() {
|
||||
const disabled = String(process.env.DAILY_INVENTORY_NOTIFICATION_ENABLED || 'true').trim().toLowerCase() === 'false';
|
||||
if (disabled) {
|
||||
console.log('[DailyInventoryNotify] 已通过环境变量关闭');
|
||||
return null;
|
||||
}
|
||||
if (timer) return timer;
|
||||
// 每分钟检查一次:命中配置的通知时间才真正执行,配置改动无需重启
|
||||
timer = cron.schedule('* * * * *', async () => {
|
||||
try {
|
||||
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
if (!config.enabled) return;
|
||||
const now = new Date();
|
||||
if (!(await shouldRunScheduled(config, now))) return;
|
||||
await runDailyInventoryNotificationJob({ mode: 'scheduled', now });
|
||||
} catch (error) {
|
||||
console.error('[DailyInventoryNotify] 定时检查失败:', error.message);
|
||||
}
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
console.log(
|
||||
`[Scheduler] 每日进销存截图通知已注册(按配置时间触发,${TIMEZONE},`
|
||||
+ `失败后 ${RETRY_WINDOW_MINUTES} 分钟内最多补跑 ${MAX_SCHEDULED_ATTEMPTS_PER_DAY} 次)`
|
||||
);
|
||||
return timer;
|
||||
}
|
||||
|
||||
function stopDailyInventoryNotificationScheduler() {
|
||||
if (timer) {
|
||||
timer.stop();
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTION_TITLE,
|
||||
LOCK_KEY,
|
||||
MAX_ATTEMPTS,
|
||||
MAX_SCHEDULED_ATTEMPTS_PER_DAY,
|
||||
NOTIFICATION_TITLE,
|
||||
RETRY_BASE_DELAY_MS,
|
||||
TIMEZONE,
|
||||
RETRY_WINDOW_MINUTES,
|
||||
beijingTimeText,
|
||||
buildNotificationContent,
|
||||
currentBeijingHhMm,
|
||||
minutesOfDay,
|
||||
resolveBizDate,
|
||||
runDailyInventoryNotificationJob,
|
||||
startDailyInventoryNotificationScheduler,
|
||||
stopDailyInventoryNotificationScheduler,
|
||||
withRetry,
|
||||
};
|
||||
330
backend/src/services/dailyInventoryNotificationSettings.js
Normal file
330
backend/src/services/dailyInventoryNotificationSettings.js
Normal file
@ -0,0 +1,330 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 定时截图通知 —— 配置 + 执行记录 + 幂等记录(本地库 system_config)
|
||||
//
|
||||
// ── 配置键 daily_inventory_notification_config ────────────────────
|
||||
// { enabled, notifyTime: 'HH:mm', recipients: ['张三', '李四'] }
|
||||
//
|
||||
// ── 状态键 daily_inventory_notification_state ──────────────────────
|
||||
// { version, notified: { 'YYYY-MM-DD': { '别名': { notifiedAt, deliveryId } } }, runs: [...] }
|
||||
// · notified 用于「同一业务日期 + 同一用户只成功通知一次」的幂等判断
|
||||
// · runs 保留最近 N 次执行明细,供管理员在页面查询
|
||||
//
|
||||
// 安全约定:本文件对外只返回「是否已配置」这类布尔值与脱敏预览,
|
||||
// 绝不返回通知中心 projectSecret、对象存储 secretAccessKey 等密钥。
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
toAdminConfig: toNotificationCenterAdminConfig,
|
||||
} = require('./yydNotificationCenterSettings');
|
||||
const { getDefaultS3Config } = require('./objectStorage');
|
||||
|
||||
const CONFIG_KEY = 'daily_inventory_notification_config';
|
||||
const STATE_KEY = 'daily_inventory_notification_state';
|
||||
|
||||
const DEFAULT_NOTIFY_TIME = '01:00';
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const MAX_RECIPIENTS = 20;
|
||||
const MAX_RECIPIENT_LENGTH = 50;
|
||||
const MAX_RUN_RECORDS = 30;
|
||||
const CONFIG_CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function normalizeText(value, maxLength = 200) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback = {}) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || ''));
|
||||
return parsed && typeof parsed === 'object' ? parsed : fallback;
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFlag(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知时间规范化:只接受 HH:mm(24 小时制),非法值回退默认 01:00。
|
||||
* 允许传 'HH:mm:ss',截断到分钟。
|
||||
*/
|
||||
function normalizeNotifyTime(value) {
|
||||
const text = normalizeText(value, 10);
|
||||
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(text);
|
||||
if (!match) return DEFAULT_NOTIFY_TIME;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) return DEFAULT_NOTIFY_TIME;
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return DEFAULT_NOTIFY_TIME;
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收件人名单规范化:trim → 去空 → 去重(保持输入顺序)→ 限长限量。
|
||||
* 支持数组、逗号/换行/分号分隔的字符串。
|
||||
*/
|
||||
function normalizeRecipients(input) {
|
||||
let list = [];
|
||||
if (Array.isArray(input)) {
|
||||
list = input;
|
||||
} else if (typeof input === 'string') {
|
||||
list = input.split(/[,,;;\n\r\t]+/);
|
||||
} else if (input && typeof input === 'object') {
|
||||
list = Object.values(input);
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const raw of list) {
|
||||
const name = normalizeText(raw, MAX_RECIPIENT_LENGTH);
|
||||
if (!name) continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
result.push(name);
|
||||
if (result.length >= MAX_RECIPIENTS) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 幂等键:正式推送按「业务日期 + 收件人」去重;测试推送带时间戳,不污染正式记录 */
|
||||
function buildIdempotencyKey({ bizDate, recipient, mode = 'scheduled', nonce = '' }) {
|
||||
const scope = mode === 'test' ? `test:${normalizeText(nonce, 64)}` : 'daily';
|
||||
return `daily_inventory_notification:${scope}:${bizDate}:${recipient}:v1`.slice(0, 160);
|
||||
}
|
||||
|
||||
async function readSystemConfigValue(key) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
return rows?.[0]?.configValue;
|
||||
}
|
||||
|
||||
async function upsertSystemConfigValue(key, value, configType = 'json') {
|
||||
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
if (rows?.[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[payload, configType, key]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: key,
|
||||
config_value: payload,
|
||||
config_type: configType,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ── 配置 ────────────────────────────────────────────────────────
|
||||
async function getDailyInventoryNotificationRuntimeConfig({ forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
let stored = {};
|
||||
try {
|
||||
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取每日进销存通知配置失败,使用默认值:', error.message);
|
||||
}
|
||||
cachedConfig = {
|
||||
enabled: normalizeFlag(stored.enabled, false),
|
||||
notifyTime: normalizeNotifyTime(stored.notifyTime ?? stored.notify_time),
|
||||
recipients: normalizeRecipients(stored.recipients),
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
async function saveDailyInventoryNotificationConfig(input = {}) {
|
||||
const current = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
const next = {
|
||||
enabled: normalizeFlag(input.enabled, current.enabled),
|
||||
notifyTime: normalizeNotifyTime(
|
||||
input.notifyTime ?? input.notify_time ?? current.notifyTime
|
||||
),
|
||||
recipients: normalizeRecipients(
|
||||
input.recipients === undefined ? current.recipients : input.recipients
|
||||
),
|
||||
};
|
||||
await upsertSystemConfigValue(CONFIG_KEY, next);
|
||||
cachedConfig = { ...next };
|
||||
cachedAt = Date.now();
|
||||
return { ...next };
|
||||
}
|
||||
|
||||
// ── 执行记录 / 幂等记录 ─────────────────────────────────────────
|
||||
async function loadDailyInventoryNotificationState() {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取每日进销存通知状态失败,按空状态处理:', error.message);
|
||||
}
|
||||
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
|
||||
? parsed.notified
|
||||
: {};
|
||||
const runs = Array.isArray(parsed.runs) ? parsed.runs : [];
|
||||
return { version: 1, notified, runs };
|
||||
}
|
||||
|
||||
async function saveDailyInventoryNotificationState(state = {}) {
|
||||
const value = {
|
||||
version: 1,
|
||||
notified: state.notified && typeof state.notified === 'object' && !Array.isArray(state.notified)
|
||||
? state.notified
|
||||
: {},
|
||||
runs: Array.isArray(state.runs) ? state.runs.slice(-MAX_RUN_RECORDS) : [],
|
||||
};
|
||||
await upsertSystemConfigValue(STATE_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 判断某业务日期是否已成功通知过某人(只认正式推送) */
|
||||
async function hasNotified(bizDate, recipient) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const day = state.notified?.[bizDate];
|
||||
return Boolean(day && day[recipient]);
|
||||
}
|
||||
|
||||
/** 记录一次成功通知(正式推送才写;测试推送不写) */
|
||||
async function markNotified(bizDate, recipient, extra = {}) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const day = { ...(state.notified?.[bizDate] || {}) };
|
||||
day[recipient] = {
|
||||
notifiedAt: new Date().toISOString(),
|
||||
deliveryId: extra.deliveryId || null,
|
||||
idempotencyKey: extra.idempotencyKey || null,
|
||||
};
|
||||
const notified = { ...(state.notified || {}), [bizDate]: day };
|
||||
// 只保留最近 14 个业务日,避免状态无限膨胀
|
||||
const keys = Object.keys(notified).sort();
|
||||
const trimmed = {};
|
||||
keys.slice(-14).forEach((k) => { trimmed[k] = notified[k]; });
|
||||
return saveDailyInventoryNotificationState({ ...state, notified: trimmed });
|
||||
}
|
||||
|
||||
/** 追加一条执行记录 */
|
||||
async function appendRunRecord(record = {}) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const runs = [...(state.runs || []), {
|
||||
runAt: record.runAt || new Date().toISOString(),
|
||||
bizDate: record.bizDate || null,
|
||||
mode: record.mode || 'scheduled',
|
||||
success: record.success === true,
|
||||
reason: record.reason || null,
|
||||
error: record.error || null,
|
||||
screenshotUrl: record.screenshotUrl || null,
|
||||
screenshotKey: record.screenshotKey || null,
|
||||
notifyTime: record.notifyTime || null,
|
||||
recipients: Array.isArray(record.recipients) ? record.recipients : [],
|
||||
}];
|
||||
return saveDailyInventoryNotificationState({ ...state, runs });
|
||||
}
|
||||
|
||||
function listRunRecords(limit = 10) {
|
||||
return loadDailyInventoryNotificationState().then((state) => {
|
||||
const runs = Array.isArray(state.runs) ? state.runs : [];
|
||||
return runs.slice(-Math.max(Number(limit) || 10, 1)).reverse();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 管理员视图(不含任何密钥) ───────────────────────────────────
|
||||
async function getObjectStorageStatus() {
|
||||
try {
|
||||
const config = await getDefaultS3Config(false);
|
||||
return {
|
||||
configured: Boolean(config && config.bucket && config.endpoint && config.accessKeyId),
|
||||
bucket: config?.bucket || '',
|
||||
endpoint: config?.endpoint || '',
|
||||
// 只给脱敏信息,secretAccessKey 从不读取、从不返回
|
||||
accessKeyIdPreview: config?.accessKeyId
|
||||
? `${String(config.accessKeyId).slice(0, 4)}***`
|
||||
: '',
|
||||
};
|
||||
} catch (error) {
|
||||
return { configured: false, bucket: '', endpoint: '', accessKeyIdPreview: '', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function getDailyInventoryNotificationAdminConfig() {
|
||||
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const centerAdmin = toNotificationCenterAdminConfig(center);
|
||||
const storage = await getObjectStorageStatus();
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const runs = Array.isArray(state.runs) ? state.runs : [];
|
||||
|
||||
const blockingReasons = [];
|
||||
if (!config.enabled) blockingReasons.push('功能未启用');
|
||||
if (!config.recipients.length) blockingReasons.push('未配置通知用户');
|
||||
if (!centerAdmin.configured) blockingReasons.push('统一通知中心未配置');
|
||||
if (!storage.configured) blockingReasons.push('对象存储未配置');
|
||||
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
notifyTime: config.notifyTime,
|
||||
recipients: config.recipients,
|
||||
timezone: TIMEZONE,
|
||||
maxRecipients: MAX_RECIPIENTS,
|
||||
notificationCenter: {
|
||||
configured: centerAdmin.configured,
|
||||
baseUrl: centerAdmin.baseUrl,
|
||||
projectCode: centerAdmin.projectCode,
|
||||
projectSecretConfigured: centerAdmin.projectSecretConfigured,
|
||||
projectSecretPreview: centerAdmin.projectSecretPreview,
|
||||
},
|
||||
objectStorage: storage,
|
||||
blockingReasons,
|
||||
lastRunAt: runs.length ? runs[runs.length - 1].runAt : null,
|
||||
lastRunSuccess: runs.length ? runs[runs.length - 1].success === true : null,
|
||||
runs: runs.slice(-10).reverse(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
STATE_KEY,
|
||||
DEFAULT_NOTIFY_TIME,
|
||||
MAX_RECIPIENTS,
|
||||
MAX_RECIPIENT_LENGTH,
|
||||
MAX_RUN_RECORDS,
|
||||
TIMEZONE,
|
||||
appendRunRecord,
|
||||
buildIdempotencyKey,
|
||||
getDailyInventoryNotificationAdminConfig,
|
||||
getDailyInventoryNotificationRuntimeConfig,
|
||||
getObjectStorageStatus,
|
||||
hasNotified,
|
||||
listRunRecords,
|
||||
loadDailyInventoryNotificationState,
|
||||
markNotified,
|
||||
normalizeFlag,
|
||||
normalizeNotifyTime,
|
||||
normalizeRecipients,
|
||||
normalizeText,
|
||||
saveDailyInventoryNotificationConfig,
|
||||
saveDailyInventoryNotificationState,
|
||||
};
|
||||
217
backend/src/services/dailyInventoryScreenshot.js
Normal file
217
backend/src/services/dailyInventoryScreenshot.js
Normal file
@ -0,0 +1,217 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 页面截图与上传
|
||||
//
|
||||
// 要点:
|
||||
// 1. 使用服务端签发的**短时效** JWT 作为专用身份访问页面(15 分钟过期),
|
||||
// 令牌只写入浏览器 localStorage,不会出现在截图地址、日志或接口响应里。
|
||||
// 2. 截图前必须等到数据真正加载完成:表格有数据行、没有 Spin 加载中、没有错误提示。
|
||||
// 3. 表格带内部滚动,直接截屏只能看到一屏;截图前注入 CSS 展开滚动容器,
|
||||
// 再整页截图,保证数据是完整的。
|
||||
// 4. 截图落在系统临时目录,上传成功后立即删除。
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { uploadFileToDefaultS3 } = require('./objectStorage');
|
||||
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const PAGE_PATH = '/planning/operation/daily-inventory';
|
||||
// 生产主站(见《服务器部署规范》):可用 DAILY_INVENTORY_SCREENSHOT_BASE_URL / PUBLIC_SITE_URL 覆盖
|
||||
const DEFAULT_BASE_URL = 'https://ck.yydd.ai';
|
||||
// 页面首次加载要扫订单明细,约 15~20 秒,等待上限给到 3 分钟
|
||||
const PAGE_READY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const SCREENSHOT_SERVICE_USER = Object.freeze({
|
||||
sub: 'daily-inventory-screenshot-bot',
|
||||
username: 'daily_inventory_screenshot_bot',
|
||||
name: '每日进销存截图服务',
|
||||
role: 'admin',
|
||||
authSource: 'service',
|
||||
allowedPageKeys: [],
|
||||
});
|
||||
|
||||
const EXPAND_CSS = `
|
||||
.ant-table-body, .ant-table-content, .ant-table-container { max-height: none !important; overflow: visible !important; }
|
||||
.daily-inventory-page { overflow: visible !important; }
|
||||
.ant-table-cell-fix-left, .ant-table-cell-fix-right { position: static !important; }
|
||||
`;
|
||||
|
||||
function normalizeText(value, maxLength = 500) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
/** 北京时间日期/时间片段:YYYY-MM-DD、YYYYMMDD、HHmmss */
|
||||
function chinaTimeParts(date = new Date()) {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
const dateText = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(target);
|
||||
const timeText = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
}).format(target);
|
||||
return {
|
||||
bizDate: dateText,
|
||||
dateKey: dateText.replace(/\D/g, ''),
|
||||
timeKey: timeText.replace(/\D/g, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBaseUrl() {
|
||||
const raw = normalizeText(
|
||||
process.env.DAILY_INVENTORY_SCREENSHOT_BASE_URL
|
||||
|| process.env.PUBLIC_SITE_URL
|
||||
|| DEFAULT_BASE_URL,
|
||||
500
|
||||
).replace(/\/+$/, '');
|
||||
return raw || DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
function resolvePageUrl() {
|
||||
return `${resolveBaseUrl()}${PAGE_PATH}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发截图专用短时效令牌。
|
||||
* 只包含页面鉴权必需的最小声明,不含任何用户凭据或密钥。
|
||||
*/
|
||||
function signScreenshotToken() {
|
||||
const secret = process.env.CUBEJS_SECRET;
|
||||
if (!secret) throw new Error('缺少 CUBEJS_SECRET,无法签发截图文身份令牌');
|
||||
return jwt.sign({ ...SCREENSHOT_SERVICE_USER }, secret, { expiresIn: '15m' });
|
||||
}
|
||||
|
||||
/** 截图存放的临时文件(不落业务目录,上传后立即删除) */
|
||||
function buildTempFilePath(bizDate, dateKey, timeKey) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-'));
|
||||
return {
|
||||
dir,
|
||||
filePath: path.join(dir, `daily-inventory-${dateKey}-${timeKey}.png`),
|
||||
objectKey: `daily-inventory/${bizDate}/daily-inventory-${dateKey}-${timeKey}.png`,
|
||||
};
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let playwright;
|
||||
try {
|
||||
playwright = require('playwright');
|
||||
} catch (_error) {
|
||||
throw new Error('未安装 playwright,无法执行页面截图');
|
||||
}
|
||||
try {
|
||||
return await playwright.chromium.launch({
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = normalizeText(error?.message, 300);
|
||||
throw new Error(`浏览器启动失败(服务器需先执行 npx playwright install chromium):${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 等到数据真正渲染完成:有数据行、无加载中、无错误提示 */
|
||||
async function waitForPageReady(page) {
|
||||
await page.waitForSelector('.daily-inventory-table .ant-table-tbody tr', {
|
||||
timeout: PAGE_READY_TIMEOUT_MS,
|
||||
state: 'attached',
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const spinning = document.querySelector('.daily-inventory-page .ant-spin-spinning');
|
||||
const skeleton = document.querySelector('.ant-skeleton');
|
||||
const rows = document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr');
|
||||
return !spinning && !skeleton && rows.length > 0;
|
||||
},
|
||||
{ timeout: PAGE_READY_TIMEOUT_MS, polling: 500 }
|
||||
);
|
||||
|
||||
const errorText = await page.evaluate(() => {
|
||||
const node = document.querySelector('.daily-inventory-page .ant-alert-error');
|
||||
return node ? node.textContent || '' : '';
|
||||
});
|
||||
if (errorText) {
|
||||
throw new Error(`页面返回错误提示,放弃截图:${normalizeText(errorText, 200)}`);
|
||||
}
|
||||
|
||||
const rowCount = await page.evaluate(
|
||||
() => document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr').length
|
||||
);
|
||||
if (!rowCount) throw new Error('页面未渲染出任何数据行,放弃截图');
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次截图并上传到默认对象存储桶
|
||||
* @returns {Promise<{url:string, key:string, bizDate:string, size:number, rowCount:number, takenAt:string}>}
|
||||
*/
|
||||
async function captureAndUploadDailyInventory(options = {}) {
|
||||
const now = options.now ? new Date(options.now) : new Date();
|
||||
const { bizDate, dateKey, timeKey } = chinaTimeParts(now);
|
||||
const token = signScreenshotToken();
|
||||
const pageUrl = resolvePageUrl();
|
||||
|
||||
const browser = await launchBrowser();
|
||||
const { dir, filePath, objectKey } = buildTempFilePath(bizDate, dateKey, timeKey);
|
||||
let rowCount = 0;
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1680, height: 1050 },
|
||||
deviceScaleFactor: 1,
|
||||
timezoneId: TIMEZONE,
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
// 令牌只注入浏览器 localStorage,URL 与日志里都不出现
|
||||
await context.addInitScript((value) => {
|
||||
try {
|
||||
window.localStorage.setItem('token', value);
|
||||
} catch (_error) {
|
||||
// 忽略:首个 document 可能还不允许访问 localStorage
|
||||
}
|
||||
}, token);
|
||||
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', () => {});
|
||||
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: PAGE_READY_TIMEOUT_MS });
|
||||
rowCount = await waitForPageReady(page);
|
||||
|
||||
// 展开内部滚动容器,保证整页截图包含完整数据区
|
||||
await page.addStyleTag({ content: EXPAND_CSS });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: filePath, fullPage: true });
|
||||
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (!stats.size) throw new Error('截图文件为空');
|
||||
|
||||
const uploaded = await uploadFileToDefaultS3(filePath, {
|
||||
key: objectKey,
|
||||
mimeType: 'image/png',
|
||||
folder: 'daily-inventory',
|
||||
extension: 'png',
|
||||
});
|
||||
|
||||
return {
|
||||
url: uploaded.url,
|
||||
key: uploaded.key,
|
||||
bizDate,
|
||||
size: stats.size,
|
||||
rowCount,
|
||||
takenAt: now.toISOString(),
|
||||
};
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
fs.rm(dir, { recursive: true, force: true }, () => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_BASE_URL,
|
||||
PAGE_PATH,
|
||||
PAGE_READY_TIMEOUT_MS,
|
||||
TIMEZONE,
|
||||
captureAndUploadDailyInventory,
|
||||
chinaTimeParts,
|
||||
resolveBaseUrl,
|
||||
resolvePageUrl,
|
||||
signScreenshotToken,
|
||||
};
|
||||
166
backend/test/dailyInventoryNotification.test.js
Normal file
166
backend/test/dailyInventoryNotification.test.js
Normal file
@ -0,0 +1,166 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
DEFAULT_NOTIFY_TIME,
|
||||
MAX_RECIPIENTS,
|
||||
buildIdempotencyKey,
|
||||
normalizeFlag,
|
||||
normalizeNotifyTime,
|
||||
normalizeRecipients,
|
||||
} = require('../src/services/dailyInventoryNotificationSettings');
|
||||
const {
|
||||
ACTION_TITLE,
|
||||
NOTIFICATION_TITLE,
|
||||
buildNotificationContent,
|
||||
minutesOfDay,
|
||||
resolveBizDate,
|
||||
withRetry,
|
||||
} = require('../src/jobs/dailyInventoryNotificationJob');
|
||||
const { chinaTimeParts } = require('../src/services/dailyInventoryScreenshot');
|
||||
|
||||
// ── 收件人名单规范化 ────────────────────────────────────────────
|
||||
test('normalizeRecipients 去空值、去空白、保持顺序', () => {
|
||||
assert.deepEqual(normalizeRecipients(['张三', ' ', '李四', '', null, undefined]), ['张三', '李四']);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 去重且忽略首尾空格', () => {
|
||||
assert.deepEqual(normalizeRecipients([' 张三 ', '张三', '李四', ' 李四']), ['张三', '李四']);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 支持逗号/中文逗号/分号/换行分隔的字符串', () => {
|
||||
assert.deepEqual(
|
||||
normalizeRecipients('张三,李四,王五;赵六\n孙七'),
|
||||
['张三', '李四', '王五', '赵六', '孙七']
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 对非法输入返回空数组', () => {
|
||||
assert.deepEqual(normalizeRecipients(null), []);
|
||||
assert.deepEqual(normalizeRecipients(undefined), []);
|
||||
assert.deepEqual(normalizeRecipients(12345), []);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 超量时截断到上限', () => {
|
||||
const list = Array.from({ length: MAX_RECIPIENTS + 5 }, (_, i) => `用户${i}`);
|
||||
const result = normalizeRecipients(list);
|
||||
assert.equal(result.length, MAX_RECIPIENTS);
|
||||
assert.equal(result[0], '用户0');
|
||||
});
|
||||
|
||||
// ── 通知时间校验 ────────────────────────────────────────────────
|
||||
test('normalizeNotifyTime 接受合法 HH:mm 并补零', () => {
|
||||
assert.equal(normalizeNotifyTime('1:05'), '01:05');
|
||||
assert.equal(normalizeNotifyTime('23:59'), '23:59');
|
||||
assert.equal(normalizeNotifyTime('00:00'), '00:00');
|
||||
});
|
||||
|
||||
test('normalizeNotifyTime 支持 HH:mm:ss 并截断到分钟', () => {
|
||||
assert.equal(normalizeNotifyTime('01:00:30'), '01:00');
|
||||
});
|
||||
|
||||
test('normalizeNotifyTime 非法值回退默认 01:00', () => {
|
||||
assert.equal(normalizeNotifyTime('24:00'), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime('12:60'), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime('abc'), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime(''), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime(null), DEFAULT_NOTIFY_TIME);
|
||||
});
|
||||
|
||||
test('normalizeFlag 只接受明确的布尔语义,未知值回退默认', () => {
|
||||
assert.equal(normalizeFlag('true', false), true);
|
||||
assert.equal(normalizeFlag('off', true), false);
|
||||
assert.equal(normalizeFlag('maybe', true), true);
|
||||
assert.equal(normalizeFlag(null, false), false);
|
||||
});
|
||||
|
||||
// ── 幂等键 ──────────────────────────────────────────────────────
|
||||
test('buildIdempotencyKey 正式推送按业务日期+收件人稳定生成', () => {
|
||||
const a = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
||||
const b = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
||||
const c = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '李四', mode: 'scheduled' });
|
||||
assert.equal(a, b);
|
||||
assert.notEqual(a, c);
|
||||
});
|
||||
|
||||
test('buildIdempotencyKey 测试推送带随机数,不占用正式幂等记录', () => {
|
||||
const formal = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
||||
const t1 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n1' });
|
||||
const t2 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n2' });
|
||||
assert.notEqual(t1, t2);
|
||||
assert.notEqual(t1, formal);
|
||||
});
|
||||
|
||||
// ── 通知正文 ────────────────────────────────────────────────────
|
||||
test('buildNotificationContent 包含数据日期、生成时间、图片地址与页面链接', () => {
|
||||
const content = buildNotificationContent({
|
||||
bizDate: '2026-09-25',
|
||||
takenAt: '2026/09/25 01:00:00',
|
||||
imageUrl: 'https://cdn.example.com/daily-inventory/2026-09-25/daily-inventory-20260925-010000.png',
|
||||
pageUrl: 'https://ck.yydd.ai/planning/operation/daily-inventory',
|
||||
});
|
||||
assert.match(content, /2026-09-25/);
|
||||
assert.match(content, /2026\/09\/25 01:00:00/);
|
||||
assert.match(content, /https:\/\/cdn\.example\.com\//);
|
||||
assert.match(content, /https:\/\/ck\.yydd\.ai\/planning\/operation\/daily-inventory/);
|
||||
assert.match(content, new RegExp(ACTION_TITLE));
|
||||
assert.equal(NOTIFICATION_TITLE, '每日进销存数据');
|
||||
});
|
||||
|
||||
// ── 业务日期与调度时刻 ──────────────────────────────────────────
|
||||
test('resolveBizDate 取北京时间昨日(与页面 T-1 口径一致)', () => {
|
||||
assert.equal(resolveBizDate(new Date('2026-09-25T01:00:00+08:00')), '2026-09-24');
|
||||
assert.equal(resolveBizDate(new Date('2026-09-25T23:30:00+08:00')), '2026-09-24');
|
||||
});
|
||||
|
||||
test('minutesOfDay 正确换算,非法值返回 null', () => {
|
||||
assert.equal(minutesOfDay('00:00'), 0);
|
||||
assert.equal(minutesOfDay('01:00'), 60);
|
||||
assert.equal(minutesOfDay('23:59'), 1439);
|
||||
assert.equal(minutesOfDay('24:00'), null);
|
||||
assert.equal(minutesOfDay('abc'), null);
|
||||
assert.equal(minutesOfDay(null), null);
|
||||
});
|
||||
|
||||
test('chinaTimeParts 生成对象键所需的日期与时间片段', () => {
|
||||
const parts = chinaTimeParts(new Date('2026-09-25T01:00:00+08:00'));
|
||||
assert.equal(parts.bizDate, '2026-09-25');
|
||||
assert.equal(parts.dateKey, '20260925');
|
||||
assert.equal(parts.timeKey, '010000');
|
||||
assert.equal(
|
||||
`daily-inventory/${parts.bizDate}/daily-inventory-${parts.dateKey}-${parts.timeKey}.png`,
|
||||
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
|
||||
);
|
||||
});
|
||||
|
||||
// ── 重试与退避 ──────────────────────────────────────────────────
|
||||
test('withRetry 第二次成功后返回结果', async () => {
|
||||
let calls = 0;
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 2) throw new Error('临时失败');
|
||||
return 'ok';
|
||||
},
|
||||
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
|
||||
);
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('withRetry 超过次数后抛出最后一次错误', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
throw new Error(`失败${calls}`);
|
||||
},
|
||||
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
|
||||
),
|
||||
/失败3/
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
17
frontend/src/api/dailyInventoryNotification.js
Normal file
17
frontend/src/api/dailyInventoryNotification.js
Normal file
@ -0,0 +1,17 @@
|
||||
import request from './request';
|
||||
|
||||
// 每日进销存 · 定时截图通知(管理员专用)
|
||||
// 后端只返回「是否已配置」与脱敏预览,不返回任何密钥
|
||||
|
||||
export const getDailyInventoryNotificationConfig = () =>
|
||||
request.get('/api/daily-inventory-notification/config');
|
||||
|
||||
export const saveDailyInventoryNotificationConfig = (payload) =>
|
||||
request.put('/api/daily-inventory-notification/config', payload);
|
||||
|
||||
/** 立即测试:真实截图 + 上传 + 通知一次,不影响正式定时任务的幂等记录 */
|
||||
export const runDailyInventoryNotificationTest = () =>
|
||||
request.post('/api/daily-inventory-notification/test', {}, { timeout: 300000 });
|
||||
|
||||
export const getDailyInventoryNotificationRuns = (limit = 10) =>
|
||||
request.get('/api/daily-inventory-notification/runs', { params: { limit } });
|
||||
@ -1,8 +1,16 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd';
|
||||
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Alert, Button, Input, Modal, Space, Spin, Switch, Table, Tag, TimePicker, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getDailyInventory } from '../../api/dailyInventory';
|
||||
import {
|
||||
getDailyInventoryNotificationConfig,
|
||||
runDailyInventoryNotificationTest,
|
||||
saveDailyInventoryNotificationConfig,
|
||||
} from '../../api/dailyInventoryNotification.js';
|
||||
import {
|
||||
DAILY_INVENTORY_COLUMNS,
|
||||
COLUMN_SOURCE,
|
||||
@ -12,6 +20,34 @@ import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const TIME_FORMAT = 'HH:mm';
|
||||
|
||||
/** 当前登录用户是否为管理员(决定「通知设置」入口是否可见) */
|
||||
function readIsAdmin() {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem('auth_user') || 'null');
|
||||
if (stored?.role) return stored.role === 'admin';
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const payload = JSON.parse(window.atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return payload?.role === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收件人名单规范化:去空、去重、限长(后端会再校验一次) */
|
||||
function normalizeRecipientList(list = []) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const raw of list) {
|
||||
const name = String(raw ?? '').trim().slice(0, 50);
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
result.push(name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const TipTitle = ({ tip, children }) => (
|
||||
<Tooltip title={tip}>
|
||||
<span className="daily-inventory-th-tip">{children}</span>
|
||||
@ -45,6 +81,17 @@ const DailyInventory = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
// ── 定时截图通知设置 ──
|
||||
const isAdmin = useMemo(readIsAdmin, []);
|
||||
const [notifyOpen, setNotifyOpen] = useState(false);
|
||||
const [notifyLoading, setNotifyLoading] = useState(false);
|
||||
const [notifySaving, setNotifySaving] = useState(false);
|
||||
const [notifyTesting, setNotifyTesting] = useState(false);
|
||||
const [notifyConfig, setNotifyConfig] = useState(null);
|
||||
const [notifyResult, setNotifyResult] = useState(null);
|
||||
const [recipientDraft, setRecipientDraft] = useState('');
|
||||
const [notifyForm, setNotifyForm] = useState({ enabled: false, notifyTime: '01:00', recipients: [] });
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@ -98,6 +145,98 @@ const DailyInventory = () => {
|
||||
|
||||
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
|
||||
|
||||
// ── 通知设置:读取 / 保存 / 立即测试 ──
|
||||
const applyNotifyConfig = useCallback((config = {}) => {
|
||||
setNotifyConfig(config);
|
||||
setNotifyForm({
|
||||
enabled: config.enabled === true,
|
||||
notifyTime: config.notifyTime || '01:00',
|
||||
recipients: normalizeRecipientList(config.recipients || []),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadNotifyConfig = useCallback(async () => {
|
||||
setNotifyLoading(true);
|
||||
try {
|
||||
const res = await getDailyInventoryNotificationConfig();
|
||||
applyNotifyConfig(res?.data ?? {});
|
||||
} catch (err) {
|
||||
setNotifyConfig({ loadError: err?.response?.data?.error || err?.message || '加载通知设置失败' });
|
||||
} finally {
|
||||
setNotifyLoading(false);
|
||||
}
|
||||
}, [applyNotifyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notifyOpen) {
|
||||
setNotifyResult(null);
|
||||
loadNotifyConfig();
|
||||
}
|
||||
}, [notifyOpen, loadNotifyConfig]);
|
||||
|
||||
const saveNotifySettings = useCallback(async () => {
|
||||
setNotifySaving(true);
|
||||
setNotifyResult(null);
|
||||
try {
|
||||
const res = await saveDailyInventoryNotificationConfig({
|
||||
enabled: notifyForm.enabled,
|
||||
notifyTime: notifyForm.notifyTime,
|
||||
recipients: normalizeRecipientList(notifyForm.recipients),
|
||||
});
|
||||
applyNotifyConfig(res?.data?.admin ?? res?.data ?? {});
|
||||
setNotifyResult({ type: 'success', text: '通知设置已保存' });
|
||||
} catch (err) {
|
||||
setNotifyResult({
|
||||
type: 'error',
|
||||
text: err?.response?.data?.error || err?.message || '保存通知设置失败',
|
||||
});
|
||||
} finally {
|
||||
setNotifySaving(false);
|
||||
}
|
||||
}, [notifyForm, applyNotifyConfig]);
|
||||
|
||||
const runNotifyTest = useCallback(async () => {
|
||||
setNotifyTesting(true);
|
||||
setNotifyResult(null);
|
||||
try {
|
||||
const res = await runDailyInventoryNotificationTest();
|
||||
const result = res?.data ?? {};
|
||||
if (result.success) {
|
||||
setNotifyResult({
|
||||
type: 'success',
|
||||
text: `测试完成:截图已上传,成功通知 ${(result.recipients || []).filter((r) => r.status === 'sent').length} 人`,
|
||||
url: result.screenshotUrl,
|
||||
});
|
||||
} else {
|
||||
setNotifyResult({ type: 'error', text: `测试未成功:${result.error || result.reason || '未知原因'}` });
|
||||
}
|
||||
loadNotifyConfig();
|
||||
} catch (err) {
|
||||
setNotifyResult({
|
||||
type: 'error',
|
||||
text: err?.response?.data?.error || err?.message || '执行测试失败',
|
||||
});
|
||||
} finally {
|
||||
setNotifyTesting(false);
|
||||
}
|
||||
}, [loadNotifyConfig]);
|
||||
|
||||
const addRecipients = useCallback(() => {
|
||||
const incoming = normalizeRecipientList(String(recipientDraft || '').split(/[,,;;\n]+/));
|
||||
if (!incoming.length) return;
|
||||
setNotifyForm((old) => ({
|
||||
...old,
|
||||
recipients: normalizeRecipientList([...(old.recipients || []), ...incoming]),
|
||||
}));
|
||||
setRecipientDraft('');
|
||||
}, [recipientDraft]);
|
||||
|
||||
const removeRecipient = useCallback((name) => {
|
||||
setNotifyForm((old) => ({
|
||||
...old,
|
||||
recipients: (old.recipients || []).filter((item) => item !== name),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SycmPage className="daily-inventory-page">
|
||||
@ -129,6 +268,9 @@ const DailyInventory = () => {
|
||||
) : null}
|
||||
</div>
|
||||
<Space>
|
||||
{isAdmin ? (
|
||||
<Button icon={<BellOutlined />} onClick={() => setNotifyOpen(true)}>通知设置</Button>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load(false)} loading={loading}>刷新</Button>
|
||||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细重算周转率与分级(约 15~20 秒)">
|
||||
<Button onClick={() => load(true)} loading={loading}>强制重算</Button>
|
||||
@ -172,6 +314,159 @@ const DailyInventory = () => {
|
||||
共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title="定时截图通知"
|
||||
open={notifyOpen}
|
||||
onCancel={() => setNotifyOpen(false)}
|
||||
width={720}
|
||||
destroyOnHidden
|
||||
styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }}
|
||||
footer={(
|
||||
<Space wrap>
|
||||
<Button type="primary" loading={notifySaving} disabled={notifyLoading} onClick={saveNotifySettings}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Tooltip title="真实执行一次截图、上传与通知;测试不会占用正式推送的幂等记录">
|
||||
<Button loading={notifyTesting} disabled={notifyLoading} onClick={runNotifyTest}>
|
||||
立即测试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={() => setNotifyOpen(false)}>关闭</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Spin spinning={notifyLoading}>
|
||||
{notifyConfig?.loadError ? (
|
||||
<Alert type="error" showIcon message={notifyConfig.loadError} style={{ marginBottom: 12 }} />
|
||||
) : null}
|
||||
|
||||
{notifyConfig?.blockingReasons?.length ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="当前不会自动推送,原因如下"
|
||||
description={<span>{notifyConfig.blockingReasons.join(';')}</span>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="daily-inventory-notify-form">
|
||||
<div className="daily-inventory-notify-row">
|
||||
<span className="daily-inventory-notify-label">启用每日截图通知</span>
|
||||
<Switch
|
||||
checked={notifyForm.enabled}
|
||||
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="daily-inventory-notify-row">
|
||||
<span className="daily-inventory-notify-label">通知时间</span>
|
||||
<TimePicker
|
||||
format={TIME_FORMAT}
|
||||
minuteStep={5}
|
||||
allowClear={false}
|
||||
value={dayjs(`2026-01-01T${notifyForm.notifyTime}:00`)}
|
||||
onChange={(value) => setNotifyForm((old) => ({
|
||||
...old,
|
||||
notifyTime: value ? value.format(TIME_FORMAT) : '01:00',
|
||||
}))}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Text type="secondary">({notifyConfig?.timezone || 'Asia/Shanghai'},默认 01:00)</Text>
|
||||
</div>
|
||||
|
||||
<div className="daily-inventory-notify-row">
|
||||
<span className="daily-inventory-notify-label">通知用户</span>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="输入通知中心中的姓名或别名,回车添加;多个可用逗号分隔"
|
||||
value={recipientDraft}
|
||||
onChange={(e) => setRecipientDraft(e.target.value)}
|
||||
onPressEnter={addRecipients}
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addRecipients} disabled={!String(recipientDraft || '').trim()}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="daily-inventory-notify-recipients">
|
||||
{notifyForm.recipients.length ? (
|
||||
notifyForm.recipients.map((name) => (
|
||||
<Tag key={name} closable onClose={() => removeRecipient(name)}>{name}</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">尚未添加通知用户</Text>
|
||||
)}
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
自动去除空值与重复姓名,最多 {notifyConfig?.maxRecipients || 20} 人。
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{notifyResult ? (
|
||||
<Alert
|
||||
type={notifyResult.type}
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={notifyResult.text}
|
||||
description={notifyResult.url ? (
|
||||
<a href={notifyResult.url} target="_blank" rel="noreferrer">{notifyResult.url}</a>
|
||||
) : null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{notifyConfig && !notifyConfig.loadError ? (
|
||||
<div className="daily-inventory-notify-meta">
|
||||
<div>
|
||||
统一通知中心:
|
||||
<Text type={notifyConfig.notificationCenter?.configured ? 'success' : 'warning'} strong>
|
||||
{notifyConfig.notificationCenter?.configured ? '已配置' : '未配置'}
|
||||
</Text>
|
||||
{notifyConfig.notificationCenter?.baseUrl ? (
|
||||
<Text type="secondary">{` ${notifyConfig.notificationCenter.baseUrl}`}</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
对象存储:
|
||||
<Text type={notifyConfig.objectStorage?.configured ? 'success' : 'warning'} strong>
|
||||
{notifyConfig.objectStorage?.configured ? '已配置' : '未配置'}
|
||||
</Text>
|
||||
{notifyConfig.objectStorage?.bucket ? (
|
||||
<Text type="secondary">{` 桶 ${notifyConfig.objectStorage.bucket}`}</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
上次执行:
|
||||
<b>{notifyConfig.lastRunAt ? new Date(notifyConfig.lastRunAt).toLocaleString('zh-CN') : '—'}</b>
|
||||
{notifyConfig.lastRunSuccess === null ? '' : (
|
||||
<Text type={notifyConfig.lastRunSuccess ? 'success' : 'danger'}>
|
||||
{` ${notifyConfig.lastRunSuccess ? '成功' : '失败'}`}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notifyConfig?.runs?.length ? (
|
||||
<div className="daily-inventory-notify-runs">
|
||||
<Text strong>最近执行记录</Text>
|
||||
{notifyConfig.runs.slice(0, 5).map((run, index) => (
|
||||
<div key={`${run.runAt}-${index}`} className="daily-inventory-notify-run">
|
||||
<span>{new Date(run.runAt).toLocaleString('zh-CN')}</span>
|
||||
<Text type={run.success ? 'success' : 'danger'}>
|
||||
{run.success ? '成功' : `失败(${run.error || run.reason || '未知原因'})`}
|
||||
</Text>
|
||||
{run.screenshotUrl ? (
|
||||
<a href={run.screenshotUrl} target="_blank" rel="noreferrer">截图</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="计算口径说明"
|
||||
open={helpOpen}
|
||||
|
||||
@ -83,6 +83,59 @@
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* 定时截图通知设置弹窗 */
|
||||
.daily-inventory-notify-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-label {
|
||||
width: 120px;
|
||||
flex: none;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.daily-inventory-notify-recipients {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-left: 128px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-meta {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-runs {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-run {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.daily-inventory-footer {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user