feat: improve yuntu report sync flow

This commit is contained in:
wxs 2026-04-13 21:08:30 +08:00
parent c8b5d27078
commit bcc5afa291
8 changed files with 383 additions and 346 deletions

View File

@ -1,55 +1,11 @@
DROP TABLE IF EXISTS public.yuntu_report_info; DROP TABLE IF EXISTS public.segmented_market_reports;
CREATE TABLE public.yuntu_report_info ( CREATE TABLE public.segmented_market_reports (
id BIGSERIAL PRIMARY KEY, report_id TEXT NOT NULL,
report_id VARCHAR(64) NOT NULL, report_info JSONB NOT NULL,
source_type VARCHAR(32) NOT NULL, completion_notified_at TIMESTAMP WITH TIME ZONE NULL,
source_report_id VARCHAR(64) NULL, details_synced_at TIMESTAMP WITH TIME ZONE NULL,
aadvid VARCHAR(32) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
name TEXT NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
price JSONB NOT NULL DEFAULT '[]'::jsonb, CONSTRAINT segmented_market_reports_pkey PRIMARY KEY (report_id)
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb,
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb,
start_time DATE NOT NULL,
end_time DATE NOT NULL,
period_type VARCHAR(32) NULL,
user_name TEXT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id),
CONSTRAINT chk_yuntu_report_info_source_type
CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')),
CONSTRAINT chk_yuntu_report_info_copy_source
CHECK (
(source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL)
OR
(source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL)
)
); );
CREATE INDEX idx_yuntu_report_info_source_report_id
ON public.yuntu_report_info (source_report_id);
CREATE INDEX idx_yuntu_report_info_created_at
ON public.yuntu_report_info (created_at DESC);
CREATE INDEX idx_yuntu_report_info_aadvid_created_at
ON public.yuntu_report_info (aadvid, created_at DESC);
CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info;
CREATE TRIGGER trg_yuntu_report_info_updated_at
BEFORE UPDATE ON public.yuntu_report_info
FOR EACH ROW
EXECUTE FUNCTION public.set_yuntu_report_info_updated_at();

View File

@ -1,33 +1,18 @@
const INSERT_REPORT_SQL = ` const INSERT_REPORT_SQL = `
INSERT INTO public.yuntu_report_info ( INSERT INTO public.segmented_market_reports (
report_id, report_id,
source_type, report_info
source_report_id,
aadvid,
name,
price,
rules,
analysis_dims,
categories,
transaction_channels,
start_time,
end_time,
period_type,
user_name,
payload
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $1, $2::jsonb
$6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
$11, $12, $13, $14, $15::jsonb
) )
ON CONFLICT (report_id) DO NOTHING ON CONFLICT (report_id) DO NOTHING
RETURNING id, report_id RETURNING report_id
`; `;
const FIND_REPORT_SQL = ` const FIND_REPORT_SQL = `
SELECT id, report_id SELECT report_id
FROM public.yuntu_report_info FROM public.segmented_market_reports
WHERE report_id = $1 WHERE report_id = $1
`; `;
@ -36,20 +21,7 @@ function createRepository(pool) {
async save(record) { async save(record) {
const insertValues = [ const insertValues = [
record.report_id, record.report_id,
record.source_type, JSON.stringify(record.report_info),
record.source_report_id,
record.aadvid,
record.name,
JSON.stringify(record.price),
JSON.stringify(record.rules),
JSON.stringify(record.analysis_dims),
JSON.stringify(record.categories),
JSON.stringify(record.transaction_channels),
record.start_time,
record.end_time,
record.period_type,
record.user_name,
JSON.stringify(record.payload),
]; ];
const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues); const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues);
@ -57,7 +29,6 @@ function createRepository(pool) {
if (insertResult.rows.length > 0) { if (insertResult.rows.length > 0) {
const row = insertResult.rows[0]; const row = insertResult.rows[0];
return { return {
id: row.id,
reportId: row.report_id, reportId: row.report_id,
created: true, created: true,
}; };
@ -71,7 +42,6 @@ function createRepository(pool) {
} }
return { return {
id: existingRow.id,
reportId: existingRow.report_id, reportId: existingRow.report_id,
created: false, created: false,
}; };

View File

@ -1,6 +1,3 @@
const ALLOWED_SOURCE_TYPES = new Set(['MANUAL_CAPTURE', 'AUTO_COPY']);
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
class ValidationError extends Error { class ValidationError extends Error {
constructor(message) { constructor(message) {
super(message); super(message);
@ -9,30 +6,6 @@ class ValidationError extends Error {
} }
} }
function ensureNonEmptyString(value, fieldName) {
if (typeof value !== 'string' || value.trim() === '') {
throw new ValidationError(`${fieldName} is required`);
}
return value.trim();
}
function ensureOptionalString(value, fieldName) {
if (value == null || value === '') {
return null;
}
return ensureNonEmptyString(value, fieldName);
}
function ensureArray(value, fieldName) {
if (!Array.isArray(value)) {
throw new ValidationError(`${fieldName} must be an array`);
}
return value;
}
function ensureObject(value, fieldName) { function ensureObject(value, fieldName) {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ValidationError(`${fieldName} must be an object`); throw new ValidationError(`${fieldName} must be an object`);
@ -41,75 +14,60 @@ function ensureObject(value, fieldName) {
return value; return value;
} }
function ensureDateString(value, fieldName) { function normalizeReportId(value) {
const normalized = ensureNonEmptyString(value, fieldName); if (typeof value === 'string' && value.trim() !== '') {
return value.trim();
if (!DATE_PATTERN.test(normalized)) {
throw new ValidationError(`${fieldName} must match YYYY-MM-DD`);
} }
return normalized; if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return null;
}
function extractReportId(reportInfo) {
const directReportId =
normalizeReportId(reportInfo.report_id) ||
normalizeReportId(reportInfo.reportId);
if (directReportId) {
return directReportId;
}
if (!reportInfo.data || typeof reportInfo.data !== 'object' || Array.isArray(reportInfo.data)) {
return null;
}
return (
normalizeReportId(reportInfo.data.report_id) ||
normalizeReportId(reportInfo.data.reportId)
);
} }
function validateAndNormalizeReportInput(input) { function validateAndNormalizeReportInput(input) {
const payload = ensureObject(input, 'body'); const reportInfo = ensureObject(input, 'body');
const sourceType = ensureNonEmptyString(payload.sourceType, 'sourceType'); const reportId = extractReportId(reportInfo);
if (!ALLOWED_SOURCE_TYPES.has(sourceType)) { if (!reportId) {
throw new ValidationError('sourceType must be MANUAL_CAPTURE or AUTO_COPY'); throw new ValidationError('report_id is required in response info');
}
const sourceReportId = ensureOptionalString(payload.sourceReportId, 'sourceReportId');
if (sourceType === 'MANUAL_CAPTURE' && sourceReportId !== null) {
throw new ValidationError('sourceReportId must be empty for MANUAL_CAPTURE');
}
if (sourceType === 'AUTO_COPY' && sourceReportId === null) {
throw new ValidationError('sourceReportId is required when sourceType is AUTO_COPY');
} }
return { return {
reportId: ensureNonEmptyString(payload.reportId, 'reportId'), reportId,
sourceType, reportInfo,
sourceReportId,
aadvid: ensureNonEmptyString(payload.aadvid, 'aadvid'),
name: ensureNonEmptyString(payload.name, 'name'),
price: ensureArray(payload.price, 'price'),
rules: ensureArray(payload.rules, 'rules'),
analysisDims: ensureArray(payload.analysisDims, 'analysisDims'),
categories: ensureArray(payload.categories, 'categories'),
channels: ensureArray(payload.channels, 'channels'),
startTime: ensureDateString(payload.startTime, 'startTime'),
endTime: ensureDateString(payload.endTime, 'endTime'),
periodType: ensureOptionalString(payload.periodType, 'periodType'),
userName: ensureOptionalString(payload.userName, 'userName'),
payload: ensureObject(payload.payload, 'payload'),
}; };
} }
function toDatabaseRecord(normalizedReport) { function toDatabaseRecord(normalizedReport) {
return { return {
report_id: normalizedReport.reportId, report_id: normalizedReport.reportId,
source_type: normalizedReport.sourceType, report_info: normalizedReport.reportInfo,
source_report_id: normalizedReport.sourceReportId,
aadvid: normalizedReport.aadvid,
name: normalizedReport.name,
price: normalizedReport.price,
rules: normalizedReport.rules,
analysis_dims: normalizedReport.analysisDims,
categories: normalizedReport.categories,
transaction_channels: normalizedReport.channels,
start_time: normalizedReport.startTime,
end_time: normalizedReport.endTime,
period_type: normalizedReport.periodType,
user_name: normalizedReport.userName,
payload: normalizedReport.payload,
}; };
} }
module.exports = { module.exports = {
ValidationError, ValidationError,
extractReportId,
validateAndNormalizeReportInput, validateAndNormalizeReportInput,
toDatabaseRecord, toDatabaseRecord,
}; };

View File

@ -35,7 +35,6 @@ function createApp({ repository, allowedOrigin }) {
response.status(saved.created ? 201 : 200).json({ response.status(saved.created ? 201 : 200).json({
success: true, success: true,
data: { data: {
id: saved.id,
reportId: saved.reportId, reportId: saved.reportId,
created: saved.created, created: saved.created,
}, },

View File

@ -6,90 +6,71 @@ const {
toDatabaseRecord, toDatabaseRecord,
} = require('../src/report-service'); } = require('../src/report-service');
function createManualPayload() { function createResponseInfo() {
return { return {
reportId: 'report-001', code: 0,
sourceType: 'MANUAL_CAPTURE', message: 'success',
sourceReportId: null, data: {
aadvid: '1648829117232140', reportId: 'report-001',
name: '测试报告', status: 'SUCCESS',
price: ['1,100', '101,100000'],
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
analysisDims: ['MARKETOVERVIEW'],
categories: [{ id: '20028', name: '奶粉类目' }],
channels: ['ALL'],
startTime: '2025-03-01',
endTime: '2026-02-28',
periodType: 'MONTH',
userName: 'tester@example.com',
payload: {
name: '测试报告', name: '测试报告',
startTime: '2025-03-01',
endTime: '2026-02-28',
}, },
}; };
} }
test('validateAndNormalizeReportInput accepts a valid manual report payload', () => { test('validateAndNormalizeReportInput accepts response info with data.reportId', () => {
const input = createManualPayload(); const input = createResponseInfo();
const result = validateAndNormalizeReportInput(input); const result = validateAndNormalizeReportInput(input);
assert.equal(result.reportId, 'report-001'); assert.equal(result.reportId, 'report-001');
assert.equal(result.sourceType, 'MANUAL_CAPTURE'); assert.deepEqual(result.reportInfo, input);
assert.equal(result.sourceReportId, null);
assert.equal(result.aadvid, '1648829117232140');
assert.equal(result.startTime, '2025-03-01');
assert.equal(result.endTime, '2026-02-28');
}); });
test('validateAndNormalizeReportInput rejects AUTO_COPY without sourceReportId', () => { test('validateAndNormalizeReportInput accepts response info with data.report_id', () => {
const input = { const input = {
...createManualPayload(), code: 0,
reportId: 'report-002', data: {
sourceType: 'AUTO_COPY', report_id: 'report-002',
sourceReportId: '', },
}; };
const result = validateAndNormalizeReportInput(input);
assert.equal(result.reportId, 'report-002');
assert.deepEqual(result.reportInfo, input);
});
test('validateAndNormalizeReportInput rejects response info without a report id', () => {
assert.throws( assert.throws(
() => validateAndNormalizeReportInput(input), () =>
validateAndNormalizeReportInput({
code: 0,
data: {},
}),
(error) => { (error) => {
assert.equal(error.code, 'VALIDATION_ERROR'); assert.equal(error.code, 'VALIDATION_ERROR');
assert.match(error.message, /sourceReportId/i); assert.match(error.message, /report[_ ]?id/i);
return true; return true;
}, },
); );
}); });
test('toDatabaseRecord maps API payload fields into database-ready values', () => { test('toDatabaseRecord maps response info into segmented_market_reports columns', () => {
const normalized = validateAndNormalizeReportInput({ const normalized = validateAndNormalizeReportInput(createResponseInfo());
...createManualPayload(),
reportId: 'report-003',
sourceType: 'AUTO_COPY',
sourceReportId: 'report-001',
});
const record = toDatabaseRecord(normalized); const record = toDatabaseRecord(normalized);
assert.deepEqual(record, { assert.deepEqual(record, {
report_id: 'report-003', report_id: 'report-001',
source_type: 'AUTO_COPY', report_info: {
source_report_id: 'report-001', code: 0,
aadvid: '1648829117232140', message: 'success',
name: '测试报告', data: {
price: ['1,100', '101,100000'], reportId: 'report-001',
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }], status: 'SUCCESS',
analysis_dims: ['MARKETOVERVIEW'], name: '测试报告',
categories: [{ id: '20028', name: '奶粉类目' }], },
transaction_channels: ['ALL'],
start_time: '2025-03-01',
end_time: '2026-02-28',
period_type: 'MONTH',
user_name: 'tester@example.com',
payload: {
name: '测试报告',
startTime: '2025-03-01',
endTime: '2026-02-28',
}, },
}); });
}); });

View File

@ -3,26 +3,13 @@ const assert = require('node:assert/strict');
const { createApp } = require('../src/server'); const { createApp } = require('../src/server');
function createManualPayload() { function createResponseInfo() {
return { return {
reportId: 'report-001', code: 0,
sourceType: 'MANUAL_CAPTURE', message: 'success',
sourceReportId: null, data: {
aadvid: '1648829117232140', reportId: 'report-001',
name: '测试报告', status: 'SUCCESS',
price: ['1,100', '101,100000'],
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
analysisDims: ['MARKETOVERVIEW'],
categories: [{ id: '20028', name: '奶粉类目' }],
channels: ['ALL'],
startTime: '2025-03-01',
endTime: '2026-02-28',
periodType: 'MONTH',
userName: 'tester@example.com',
payload: {
name: '测试报告',
startTime: '2025-03-01',
endTime: '2026-02-28',
}, },
}; };
} }
@ -78,7 +65,7 @@ test('GET /health returns ok status', async () => {
); );
}); });
test('POST /api/reports returns 400 for invalid auto copy payload', async () => { test('POST /api/reports returns 400 when response info does not contain a report id', async () => {
await withServer( await withServer(
{ {
async save() { async save() {
@ -92,9 +79,8 @@ test('POST /api/reports returns 400 for invalid auto copy payload', async () =>
'content-type': 'application/json', 'content-type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
...createManualPayload(), code: 0,
sourceType: 'AUTO_COPY', data: {},
sourceReportId: '',
}), }),
}); });
const body = await response.json(); const body = await response.json();
@ -106,7 +92,7 @@ test('POST /api/reports returns 400 for invalid auto copy payload', async () =>
); );
}); });
test('POST /api/reports persists a valid payload and returns created response', async () => { test('POST /api/reports persists raw response info and returns created response', async () => {
let savedRecord = null; let savedRecord = null;
await withServer( await withServer(
@ -114,19 +100,19 @@ test('POST /api/reports persists a valid payload and returns created response',
async save(record) { async save(record) {
savedRecord = record; savedRecord = record;
return { return {
id: 12,
reportId: record.report_id, reportId: record.report_id,
created: true, created: true,
}; };
}, },
}, },
async (baseUrl) => { async (baseUrl) => {
const responseInfo = createResponseInfo();
const response = await fetch(`${baseUrl}/api/reports`, { const response = await fetch(`${baseUrl}/api/reports`, {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
}, },
body: JSON.stringify(createManualPayload()), body: JSON.stringify(responseInfo),
}); });
const body = await response.json(); const body = await response.json();
@ -134,13 +120,14 @@ test('POST /api/reports persists a valid payload and returns created response',
assert.deepEqual(body, { assert.deepEqual(body, {
success: true, success: true,
data: { data: {
id: 12,
reportId: 'report-001', reportId: 'report-001',
created: true, created: true,
}, },
}); });
assert.equal(savedRecord.report_id, 'report-001'); assert.deepEqual(savedRecord, {
assert.equal(savedRecord.source_type, 'MANUAL_CAPTURE'); report_id: 'report-001',
report_info: responseInfo,
});
}, },
); );
}); });

View File

@ -36,6 +36,13 @@ test("isSupportedPageUrl covers both creation and detail pages", () => {
true, true,
); );
assert.equal(
api.isSupportedPageUrl(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList?aadvid=1648829117232140",
),
true,
);
assert.equal( assert.equal(
api.isSupportedPageUrl( api.isSupportedPageUrl(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/otherPage?aadvid=1710507483282439", "https://yuntu.oceanengine.com/yuntu_brand/ecom/product/otherPage?aadvid=1710507483282439",
@ -44,6 +51,105 @@ test("isSupportedPageUrl covers both creation and detail pages", () => {
); );
}); });
test("getSupportedPageType identifies list, creation, and detail pages", () => {
assert.equal(
api.getSupportedPageType(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList?aadvid=1648829117232140",
),
"list",
);
assert.equal(
api.getSupportedPageType(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140",
),
"creation",
);
assert.equal(
api.getSupportedPageType(
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/marketOverview?aadvid=1710507483282439&reportId=441518",
),
"detail",
);
});
test("getButtonUiState disables the button on the list page and enables it on action pages with payload", () => {
assert.deepEqual(
api.getButtonUiState({
pageType: "list",
hasPayload: true,
isSubmitting: false,
}),
{
shouldRender: true,
disabled: true,
},
);
assert.deepEqual(
api.getButtonUiState({
pageType: "detail",
hasPayload: true,
isSubmitting: false,
}),
{
shouldRender: true,
disabled: false,
},
);
assert.deepEqual(
api.getButtonUiState({
pageType: null,
hasPayload: true,
isSubmitting: false,
}),
{
shouldRender: false,
disabled: true,
},
);
});
test("createRouteChangeWatcher triggers a sync callback after pushState", () => {
const events = [];
const listeners = new Map();
const root = {
history: {
pushState() {
events.push("pushState");
},
replaceState() {
events.push("replaceState");
},
},
addEventListener(name, handler) {
listeners.set(name, handler);
},
removeEventListener(name) {
listeners.delete(name);
},
setTimeout(callback) {
callback();
return 1;
},
clearTimeout() {},
};
const dispose = api.createRouteChangeWatcher(root, () => {
events.push("sync");
});
root.history.pushState({}, "", "/next");
assert.deepEqual(events, ["pushState", "sync"]);
dispose();
events.length = 0;
root.history.replaceState({}, "", "/again");
assert.deepEqual(events, ["replaceState"]);
});
test("getLocalApiBaseCandidates prefers localhost and falls back to 127.0.0.1", () => { test("getLocalApiBaseCandidates prefers localhost and falls back to 127.0.0.1", () => {
assert.deepEqual(api.getLocalApiBaseCandidates(), [ assert.deepEqual(api.getLocalApiBaseCandidates(), [
"http://localhost:3000", "http://localhost:3000",
@ -93,42 +199,19 @@ test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appe
assert.notEqual(copied.nested, original.nested); assert.notEqual(copied.nested, original.nested);
}); });
test("buildPersistRequest maps manual capture payload into backend request format", () => { test("buildPersistRequest keeps the response info unchanged for backend parsing", () => {
const payload = { const responseInfo = {
name: "测试", code: 0,
price: ["1,100", "101,100000"], message: "success",
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }], data: {
analysisDims: ["MARKETOVERVIEW"], reportId: "report-001",
categories: [{ id: "20028" }], status: "SUCCESS",
channels: ["ALL"], },
startTime: "2025-03-01",
endTime: "2026-02-28",
periodType: "MONTH",
userName: "tester@example.com",
}; };
const request = api.buildPersistRequest({
payload,
aadvid: "1648829117232140",
reportId: "report-001",
sourceType: "MANUAL_CAPTURE",
sourceReportId: null,
});
assert.deepEqual(request, { const request = api.buildPersistRequest(responseInfo);
reportId: "report-001",
sourceType: "MANUAL_CAPTURE", assert.deepEqual(request, responseInfo);
sourceReportId: null, assert.notEqual(request, responseInfo);
aadvid: "1648829117232140", assert.notEqual(request.data, responseInfo.data);
name: "测试",
price: ["1,100", "101,100000"],
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }],
analysisDims: ["MARKETOVERVIEW"],
categories: [{ id: "20028" }],
channels: ["ALL"],
startTime: "2025-03-01",
endTime: "2026-02-28",
periodType: "MONTH",
userName: "tester@example.com",
payload,
});
}); });

View File

@ -4,6 +4,7 @@
// @version 0.1.0 // @version 0.1.0
// @description 记录最近一次成功创建的云图报告,并一键复制同比报告 // @description 记录最近一次成功创建的云图报告,并一键复制同比报告
// @author wangxi // @author wangxi
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList*
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation* // @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation*
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/* // @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/*
// @grant GM_getValue // @grant GM_getValue
@ -24,10 +25,28 @@
api.init(); api.init();
})(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) { })(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) {
const TARGET_PATH = "/product_node/v2/api/segmentedMarket/createSegmentedMarket"; const TARGET_PATH = "/product_node/v2/api/segmentedMarket/createSegmentedMarket";
const PAGE_TYPES = {
list: "list",
creation: "creation",
detail: "detail",
};
// 后续如果后端部署到服务器,只需要修改这里的基础地址。
// 例如改成https://your-server.example.com
// 脚本会在这些基础地址后自动拼接 /api/reports。
const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"]; const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"];
const SUPPORTED_PAGE_PATTERNS = [ const SUPPORTED_PAGE_PATTERNS = [
/^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/, {
/^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/, type: PAGE_TYPES.list,
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketList(?:\/.*)?$/,
},
{
type: PAGE_TYPES.creation,
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/,
},
{
type: PAGE_TYPES.detail,
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/,
},
]; ];
const STORAGE_KEYS = { const STORAGE_KEYS = {
payload: "lastReportPayload", payload: "lastReportPayload",
@ -42,6 +61,8 @@
isSubmitting: false, isSubmitting: false,
suppressedRequestBody: null, suppressedRequestBody: null,
clearSuppressionTimer: null, clearSuppressionTimer: null,
captureListenerAttached: false,
routeWatcherCleanup: null,
}; };
function log(message, extra) { function log(message, extra) {
@ -65,7 +86,7 @@
} }
} }
function isSupportedPageUrl(url) { function getSupportedPageType(url) {
try { try {
const parsed = new URL( const parsed = new URL(
url, url,
@ -73,14 +94,20 @@
? root.location.href ? root.location.href
: "https://yuntu.oceanengine.com", : "https://yuntu.oceanengine.com",
); );
return SUPPORTED_PAGE_PATTERNS.some((pattern) =>
const matched = SUPPORTED_PAGE_PATTERNS.find(({ pattern }) =>
pattern.test(parsed.pathname), pattern.test(parsed.pathname),
); );
return matched ? matched.type : null;
} catch (_error) { } catch (_error) {
return false; return null;
} }
} }
function isSupportedPageUrl(url) {
return getSupportedPageType(url) !== null;
}
function getLocalApiBaseCandidates() { function getLocalApiBaseCandidates() {
return [...LOCAL_API_BASES]; return [...LOCAL_API_BASES];
} }
@ -135,30 +162,8 @@
return cloned; return cloned;
} }
function buildPersistRequest({ function buildPersistRequest(responseJson) {
payload, return deepCloneJson(responseJson);
aadvid,
reportId,
sourceType,
sourceReportId,
}) {
return {
reportId: String(reportId),
sourceType,
sourceReportId: sourceReportId ? String(sourceReportId) : null,
aadvid: String(aadvid),
name: payload.name || "",
price: Array.isArray(payload.price) ? payload.price : [],
rules: Array.isArray(payload.rules) ? payload.rules : [],
analysisDims: Array.isArray(payload.analysisDims) ? payload.analysisDims : [],
categories: Array.isArray(payload.categories) ? payload.categories : [],
channels: Array.isArray(payload.channels) ? payload.channels : [],
startTime: payload.startTime || "",
endTime: payload.endTime || "",
periodType: payload.periodType || null,
userName: payload.userName || null,
payload,
};
} }
function parseJson(text) { function parseJson(text) {
@ -266,12 +271,47 @@
return; return;
} }
const pageType = getSupportedPageType(root.location && root.location.href ? root.location.href : "");
const hasPayload = Boolean(readStoredPayload()); const hasPayload = Boolean(readStoredPayload());
button.disabled = runtimeState.isSubmitting || !hasPayload; const buttonUiState = getButtonUiState({
pageType,
hasPayload,
isSubmitting: runtimeState.isSubmitting,
});
button.disabled = buttonUiState.disabled;
button.textContent = runtimeState.isSubmitting button.textContent = runtimeState.isSubmitting
? "创建中..." ? "创建中..."
: "一键复制同比报告"; : "一键复制同比报告";
button.dataset.enabled = String(hasPayload); button.dataset.enabled = String(!buttonUiState.disabled);
button.dataset.pageType = String(pageType || "");
}
function getButtonUiState({ pageType, hasPayload, isSubmitting }) {
if (!pageType) {
return {
shouldRender: false,
disabled: true,
};
}
if (pageType === PAGE_TYPES.list) {
return {
shouldRender: true,
disabled: true,
};
}
return {
shouldRender: true,
disabled: Boolean(isSubmitting || !hasPayload),
};
}
function removeButton() {
const button = root.document.getElementById(BUTTON_ID);
if (button) {
button.remove();
}
} }
async function persistToLocalServer(payload) { async function persistToLocalServer(payload) {
@ -384,7 +424,6 @@
try { try {
const nextPayload = buildAutoCopyPayload(payload); const nextPayload = buildAutoCopyPayload(payload);
const requestUrl = getCreateRequestUrl(meta); const requestUrl = getCreateRequestUrl(meta);
const sourceReportId = meta.reportId || null;
const { responseJson } = await createReportThroughPage(requestUrl, nextPayload); const { responseJson } = await createReportThroughPage(requestUrl, nextPayload);
const reportId = extractReportId(responseJson); const reportId = extractReportId(responseJson);
@ -401,15 +440,7 @@
}; };
writeStoredData(nextPayload, nextMeta); writeStoredData(nextPayload, nextMeta);
await persistToLocalServer( await persistToLocalServer(buildPersistRequest(responseJson));
buildPersistRequest({
payload: nextPayload,
aadvid: nextMeta.aadvid,
reportId,
sourceType: "AUTO_COPY",
sourceReportId,
}),
);
showToast(`同比报告创建成功:${reportId}`, "success"); showToast(`同比报告创建成功:${reportId}`, "success");
} catch (error) { } catch (error) {
@ -455,15 +486,7 @@
writeStoredData(payload, meta); writeStoredData(payload, meta);
updateButtonState(); updateButtonState();
await persistToLocalServer( await persistToLocalServer(buildPersistRequest(responseJson));
buildPersistRequest({
payload,
aadvid,
reportId,
sourceType: "MANUAL_CAPTURE",
sourceReportId: null,
}),
);
showToast(`已记录报告配置:${reportId}`, "success"); showToast(`已记录报告配置:${reportId}`, "success");
} catch (error) { } catch (error) {
@ -571,6 +594,73 @@
updateButtonState(); updateButtonState();
} }
function syncUiForCurrentPage() {
if (!root.document || !root.document.body) {
return;
}
const pageType = getSupportedPageType(root.location && root.location.href ? root.location.href : "");
const hasPayload = Boolean(readStoredPayload());
const buttonUiState = getButtonUiState({
pageType,
hasPayload,
isSubmitting: runtimeState.isSubmitting,
});
if (!buttonUiState.shouldRender) {
removeButton();
return;
}
ensureStyles();
ensureButton();
updateButtonState();
}
function createRouteChangeWatcher(targetRoot, onRouteChange) {
if (!targetRoot || !targetRoot.history || typeof onRouteChange !== "function") {
return function noop() {};
}
let timerId = null;
const schedule = () => {
if (timerId) {
targetRoot.clearTimeout(timerId);
}
timerId = targetRoot.setTimeout(() => {
timerId = null;
onRouteChange();
}, 0);
};
const originalPushState = targetRoot.history.pushState;
const originalReplaceState = targetRoot.history.replaceState;
targetRoot.history.pushState = function patchedPushState() {
const result = originalPushState.apply(this, arguments);
schedule();
return result;
};
targetRoot.history.replaceState = function patchedReplaceState() {
const result = originalReplaceState.apply(this, arguments);
schedule();
return result;
};
targetRoot.addEventListener("popstate", schedule);
return function dispose() {
if (timerId) {
targetRoot.clearTimeout(timerId);
timerId = null;
}
targetRoot.history.pushState = originalPushState;
targetRoot.history.replaceState = originalReplaceState;
targetRoot.removeEventListener("popstate", schedule);
};
}
function injectCaptureHook() { function injectCaptureHook() {
if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") { if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") {
return; return;
@ -680,34 +770,47 @@
} }
function attachCaptureListener() { function attachCaptureListener() {
if (runtimeState.captureListenerAttached) {
return;
}
root.addEventListener(EVENT_NAME, (event) => { root.addEventListener(EVENT_NAME, (event) => {
handleCapturedRequest(event.detail); handleCapturedRequest(event.detail);
}); });
runtimeState.captureListenerAttached = true;
}
function ensureRouteWatcher() {
if (runtimeState.routeWatcherCleanup) {
return;
}
runtimeState.routeWatcherCleanup = createRouteChangeWatcher(root, () => {
syncUiForCurrentPage();
});
} }
function init() { function init() {
if (!isSupportedPageUrl(root.location && root.location.href ? root.location.href : "")) {
return;
}
if (!root.document || !root.document.body) { if (!root.document || !root.document.body) {
root.addEventListener("DOMContentLoaded", init, { once: true }); root.addEventListener("DOMContentLoaded", init, { once: true });
return; return;
} }
ensureStyles(); ensureRouteWatcher();
ensureButton();
attachCaptureListener(); attachCaptureListener();
injectCaptureHook(); injectCaptureHook();
updateButtonState(); syncUiForCurrentPage();
} }
return { return {
buildAutoCopyPayload, buildAutoCopyPayload,
buildAutoCopyName, buildAutoCopyName,
buildPersistRequest, buildPersistRequest,
createRouteChangeWatcher,
extractReportId, extractReportId,
getButtonUiState,
getLocalApiBaseCandidates, getLocalApiBaseCandidates,
getSupportedPageType,
init, init,
isSupportedPageUrl, isSupportedPageUrl,
isTargetCreateReportRequest, isTargetCreateReportRequest,