From bcc5afa291b4b50e6ceb52ba41bc99594f817d30 Mon Sep 17 00:00:00 2001 From: wxs Date: Mon, 13 Apr 2026 21:08:30 +0800 Subject: [PATCH] feat: improve yuntu report sync flow --- .../yuntuReportFilling/server/sql/schema.sql | 62 +---- .../server/src/report-repository.js | 44 +--- .../server/src/report-service.js | 110 +++------ yuntu/yuntuReportFilling/server/src/server.js | 1 - .../server/test/report-service.test.js | 95 +++----- .../server/test/server.test.js | 45 ++-- .../yuntuReportFilling.test.js | 155 ++++++++++--- .../yuntuReportFilling.user.js | 217 +++++++++++++----- 8 files changed, 383 insertions(+), 346 deletions(-) diff --git a/yuntu/yuntuReportFilling/server/sql/schema.sql b/yuntu/yuntuReportFilling/server/sql/schema.sql index faba221..664ebfb 100644 --- a/yuntu/yuntuReportFilling/server/sql/schema.sql +++ b/yuntu/yuntuReportFilling/server/sql/schema.sql @@ -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 ( - id BIGSERIAL PRIMARY KEY, - report_id VARCHAR(64) NOT NULL, - source_type VARCHAR(32) NOT NULL, - source_report_id VARCHAR(64) NULL, - aadvid VARCHAR(32) NOT NULL, - name TEXT NOT NULL, - price JSONB NOT NULL DEFAULT '[]'::jsonb, - 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 TABLE public.segmented_market_reports ( + report_id TEXT NOT NULL, + report_info JSONB NOT NULL, + completion_notified_at TIMESTAMP WITH TIME ZONE NULL, + details_synced_at TIMESTAMP WITH TIME ZONE NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT segmented_market_reports_pkey PRIMARY KEY (report_id) ); - -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(); diff --git a/yuntu/yuntuReportFilling/server/src/report-repository.js b/yuntu/yuntuReportFilling/server/src/report-repository.js index ca5add8..7bdfb62 100644 --- a/yuntu/yuntuReportFilling/server/src/report-repository.js +++ b/yuntu/yuntuReportFilling/server/src/report-repository.js @@ -1,33 +1,18 @@ const INSERT_REPORT_SQL = ` - INSERT INTO public.yuntu_report_info ( + INSERT INTO public.segmented_market_reports ( report_id, - source_type, - source_report_id, - aadvid, - name, - price, - rules, - analysis_dims, - categories, - transaction_channels, - start_time, - end_time, - period_type, - user_name, - payload + report_info ) VALUES ( - $1, $2, $3, $4, $5, - $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb, - $11, $12, $13, $14, $15::jsonb + $1, $2::jsonb ) ON CONFLICT (report_id) DO NOTHING - RETURNING id, report_id + RETURNING report_id `; const FIND_REPORT_SQL = ` - SELECT id, report_id - FROM public.yuntu_report_info + SELECT report_id + FROM public.segmented_market_reports WHERE report_id = $1 `; @@ -36,20 +21,7 @@ function createRepository(pool) { async save(record) { const insertValues = [ record.report_id, - record.source_type, - 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), + JSON.stringify(record.report_info), ]; const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues); @@ -57,7 +29,6 @@ function createRepository(pool) { if (insertResult.rows.length > 0) { const row = insertResult.rows[0]; return { - id: row.id, reportId: row.report_id, created: true, }; @@ -71,7 +42,6 @@ function createRepository(pool) { } return { - id: existingRow.id, reportId: existingRow.report_id, created: false, }; diff --git a/yuntu/yuntuReportFilling/server/src/report-service.js b/yuntu/yuntuReportFilling/server/src/report-service.js index 40f9e44..169b88e 100644 --- a/yuntu/yuntuReportFilling/server/src/report-service.js +++ b/yuntu/yuntuReportFilling/server/src/report-service.js @@ -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 { constructor(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) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new ValidationError(`${fieldName} must be an object`); @@ -41,75 +14,60 @@ function ensureObject(value, fieldName) { return value; } -function ensureDateString(value, fieldName) { - const normalized = ensureNonEmptyString(value, fieldName); - - if (!DATE_PATTERN.test(normalized)) { - throw new ValidationError(`${fieldName} must match YYYY-MM-DD`); +function normalizeReportId(value) { + if (typeof value === 'string' && value.trim() !== '') { + return value.trim(); } - 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) { - const payload = ensureObject(input, 'body'); - const sourceType = ensureNonEmptyString(payload.sourceType, 'sourceType'); + const reportInfo = ensureObject(input, 'body'); + const reportId = extractReportId(reportInfo); - if (!ALLOWED_SOURCE_TYPES.has(sourceType)) { - throw new ValidationError('sourceType must be MANUAL_CAPTURE or AUTO_COPY'); - } - - 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'); + if (!reportId) { + throw new ValidationError('report_id is required in response info'); } return { - reportId: ensureNonEmptyString(payload.reportId, 'reportId'), - sourceType, - 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'), + reportId, + reportInfo, }; } function toDatabaseRecord(normalizedReport) { return { report_id: normalizedReport.reportId, - source_type: normalizedReport.sourceType, - 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, + report_info: normalizedReport.reportInfo, }; } module.exports = { ValidationError, + extractReportId, validateAndNormalizeReportInput, toDatabaseRecord, }; diff --git a/yuntu/yuntuReportFilling/server/src/server.js b/yuntu/yuntuReportFilling/server/src/server.js index ff3bb7d..c949480 100644 --- a/yuntu/yuntuReportFilling/server/src/server.js +++ b/yuntu/yuntuReportFilling/server/src/server.js @@ -35,7 +35,6 @@ function createApp({ repository, allowedOrigin }) { response.status(saved.created ? 201 : 200).json({ success: true, data: { - id: saved.id, reportId: saved.reportId, created: saved.created, }, diff --git a/yuntu/yuntuReportFilling/server/test/report-service.test.js b/yuntu/yuntuReportFilling/server/test/report-service.test.js index d0db1d2..1971204 100644 --- a/yuntu/yuntuReportFilling/server/test/report-service.test.js +++ b/yuntu/yuntuReportFilling/server/test/report-service.test.js @@ -6,90 +6,71 @@ const { toDatabaseRecord, } = require('../src/report-service'); -function createManualPayload() { +function createResponseInfo() { return { - reportId: 'report-001', - sourceType: 'MANUAL_CAPTURE', - sourceReportId: null, - aadvid: '1648829117232140', - name: '测试报告', - 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: { + code: 0, + message: 'success', + data: { + reportId: 'report-001', + status: 'SUCCESS', name: '测试报告', - startTime: '2025-03-01', - endTime: '2026-02-28', }, }; } -test('validateAndNormalizeReportInput accepts a valid manual report payload', () => { - const input = createManualPayload(); +test('validateAndNormalizeReportInput accepts response info with data.reportId', () => { + const input = createResponseInfo(); const result = validateAndNormalizeReportInput(input); assert.equal(result.reportId, 'report-001'); - assert.equal(result.sourceType, 'MANUAL_CAPTURE'); - assert.equal(result.sourceReportId, null); - assert.equal(result.aadvid, '1648829117232140'); - assert.equal(result.startTime, '2025-03-01'); - assert.equal(result.endTime, '2026-02-28'); + assert.deepEqual(result.reportInfo, input); }); -test('validateAndNormalizeReportInput rejects AUTO_COPY without sourceReportId', () => { +test('validateAndNormalizeReportInput accepts response info with data.report_id', () => { const input = { - ...createManualPayload(), - reportId: 'report-002', - sourceType: 'AUTO_COPY', - sourceReportId: '', + code: 0, + data: { + report_id: 'report-002', + }, }; + 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( - () => validateAndNormalizeReportInput(input), + () => + validateAndNormalizeReportInput({ + code: 0, + data: {}, + }), (error) => { assert.equal(error.code, 'VALIDATION_ERROR'); - assert.match(error.message, /sourceReportId/i); + assert.match(error.message, /report[_ ]?id/i); return true; }, ); }); -test('toDatabaseRecord maps API payload fields into database-ready values', () => { - const normalized = validateAndNormalizeReportInput({ - ...createManualPayload(), - reportId: 'report-003', - sourceType: 'AUTO_COPY', - sourceReportId: 'report-001', - }); +test('toDatabaseRecord maps response info into segmented_market_reports columns', () => { + const normalized = validateAndNormalizeReportInput(createResponseInfo()); const record = toDatabaseRecord(normalized); assert.deepEqual(record, { - report_id: 'report-003', - source_type: 'AUTO_COPY', - source_report_id: 'report-001', - aadvid: '1648829117232140', - name: '测试报告', - price: ['1,100', '101,100000'], - rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }], - analysis_dims: ['MARKETOVERVIEW'], - 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', + report_id: 'report-001', + report_info: { + code: 0, + message: 'success', + data: { + reportId: 'report-001', + status: 'SUCCESS', + name: '测试报告', + }, }, }); }); diff --git a/yuntu/yuntuReportFilling/server/test/server.test.js b/yuntu/yuntuReportFilling/server/test/server.test.js index 187ae94..3850ac7 100644 --- a/yuntu/yuntuReportFilling/server/test/server.test.js +++ b/yuntu/yuntuReportFilling/server/test/server.test.js @@ -3,26 +3,13 @@ const assert = require('node:assert/strict'); const { createApp } = require('../src/server'); -function createManualPayload() { +function createResponseInfo() { return { - reportId: 'report-001', - sourceType: 'MANUAL_CAPTURE', - sourceReportId: null, - aadvid: '1648829117232140', - name: '测试报告', - 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', + code: 0, + message: 'success', + data: { + reportId: 'report-001', + status: 'SUCCESS', }, }; } @@ -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( { async save() { @@ -92,9 +79,8 @@ test('POST /api/reports returns 400 for invalid auto copy payload', async () => 'content-type': 'application/json', }, body: JSON.stringify({ - ...createManualPayload(), - sourceType: 'AUTO_COPY', - sourceReportId: '', + code: 0, + data: {}, }), }); 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; await withServer( @@ -114,19 +100,19 @@ test('POST /api/reports persists a valid payload and returns created response', async save(record) { savedRecord = record; return { - id: 12, reportId: record.report_id, created: true, }; }, }, async (baseUrl) => { + const responseInfo = createResponseInfo(); const response = await fetch(`${baseUrl}/api/reports`, { method: 'POST', headers: { 'content-type': 'application/json', }, - body: JSON.stringify(createManualPayload()), + body: JSON.stringify(responseInfo), }); const body = await response.json(); @@ -134,13 +120,14 @@ test('POST /api/reports persists a valid payload and returns created response', assert.deepEqual(body, { success: true, data: { - id: 12, reportId: 'report-001', created: true, }, }); - assert.equal(savedRecord.report_id, 'report-001'); - assert.equal(savedRecord.source_type, 'MANUAL_CAPTURE'); + assert.deepEqual(savedRecord, { + report_id: 'report-001', + report_info: responseInfo, + }); }, ); }); diff --git a/yuntu/yuntuReportFilling/yuntuReportFilling.test.js b/yuntu/yuntuReportFilling/yuntuReportFilling.test.js index 865d90b..f7263fd 100644 --- a/yuntu/yuntuReportFilling/yuntuReportFilling.test.js +++ b/yuntu/yuntuReportFilling/yuntuReportFilling.test.js @@ -36,6 +36,13 @@ test("isSupportedPageUrl covers both creation and detail pages", () => { true, ); + assert.equal( + api.isSupportedPageUrl( + "https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList?aadvid=1648829117232140", + ), + true, + ); + assert.equal( api.isSupportedPageUrl( "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", () => { assert.deepEqual(api.getLocalApiBaseCandidates(), [ "http://localhost:3000", @@ -93,42 +199,19 @@ test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appe assert.notEqual(copied.nested, original.nested); }); -test("buildPersistRequest maps manual capture payload into backend request format", () => { - const payload = { - 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", +test("buildPersistRequest keeps the response info unchanged for backend parsing", () => { + const responseInfo = { + code: 0, + message: "success", + data: { + reportId: "report-001", + status: "SUCCESS", + }, }; - const request = api.buildPersistRequest({ - payload, - aadvid: "1648829117232140", - reportId: "report-001", - sourceType: "MANUAL_CAPTURE", - sourceReportId: null, - }); - assert.deepEqual(request, { - reportId: "report-001", - sourceType: "MANUAL_CAPTURE", - sourceReportId: null, - aadvid: "1648829117232140", - 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, - }); + const request = api.buildPersistRequest(responseInfo); + + assert.deepEqual(request, responseInfo); + assert.notEqual(request, responseInfo); + assert.notEqual(request.data, responseInfo.data); }); diff --git a/yuntu/yuntuReportFilling/yuntuReportFilling.user.js b/yuntu/yuntuReportFilling/yuntuReportFilling.user.js index 666952e..74d9e43 100644 --- a/yuntu/yuntuReportFilling/yuntuReportFilling.user.js +++ b/yuntu/yuntuReportFilling/yuntuReportFilling.user.js @@ -4,6 +4,7 @@ // @version 0.1.0 // @description 记录最近一次成功创建的云图报告,并一键复制同比报告 // @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/segmentedMarketDetail/* // @grant GM_getValue @@ -24,10 +25,28 @@ api.init(); })(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) { 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 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 = { payload: "lastReportPayload", @@ -42,6 +61,8 @@ isSubmitting: false, suppressedRequestBody: null, clearSuppressionTimer: null, + captureListenerAttached: false, + routeWatcherCleanup: null, }; function log(message, extra) { @@ -65,7 +86,7 @@ } } - function isSupportedPageUrl(url) { + function getSupportedPageType(url) { try { const parsed = new URL( url, @@ -73,14 +94,20 @@ ? root.location.href : "https://yuntu.oceanengine.com", ); - return SUPPORTED_PAGE_PATTERNS.some((pattern) => + + const matched = SUPPORTED_PAGE_PATTERNS.find(({ pattern }) => pattern.test(parsed.pathname), ); + return matched ? matched.type : null; } catch (_error) { - return false; + return null; } } + function isSupportedPageUrl(url) { + return getSupportedPageType(url) !== null; + } + function getLocalApiBaseCandidates() { return [...LOCAL_API_BASES]; } @@ -135,30 +162,8 @@ return cloned; } - function buildPersistRequest({ - payload, - 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 buildPersistRequest(responseJson) { + return deepCloneJson(responseJson); } function parseJson(text) { @@ -266,12 +271,47 @@ return; } + const pageType = getSupportedPageType(root.location && root.location.href ? root.location.href : ""); 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.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) { @@ -384,7 +424,6 @@ try { const nextPayload = buildAutoCopyPayload(payload); const requestUrl = getCreateRequestUrl(meta); - const sourceReportId = meta.reportId || null; const { responseJson } = await createReportThroughPage(requestUrl, nextPayload); const reportId = extractReportId(responseJson); @@ -401,15 +440,7 @@ }; writeStoredData(nextPayload, nextMeta); - await persistToLocalServer( - buildPersistRequest({ - payload: nextPayload, - aadvid: nextMeta.aadvid, - reportId, - sourceType: "AUTO_COPY", - sourceReportId, - }), - ); + await persistToLocalServer(buildPersistRequest(responseJson)); showToast(`同比报告创建成功:${reportId}`, "success"); } catch (error) { @@ -455,15 +486,7 @@ writeStoredData(payload, meta); updateButtonState(); - await persistToLocalServer( - buildPersistRequest({ - payload, - aadvid, - reportId, - sourceType: "MANUAL_CAPTURE", - sourceReportId: null, - }), - ); + await persistToLocalServer(buildPersistRequest(responseJson)); showToast(`已记录报告配置:${reportId}`, "success"); } catch (error) { @@ -571,6 +594,73 @@ 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() { if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") { return; @@ -680,34 +770,47 @@ } function attachCaptureListener() { + if (runtimeState.captureListenerAttached) { + return; + } + root.addEventListener(EVENT_NAME, (event) => { handleCapturedRequest(event.detail); }); + runtimeState.captureListenerAttached = true; + } + + function ensureRouteWatcher() { + if (runtimeState.routeWatcherCleanup) { + return; + } + + runtimeState.routeWatcherCleanup = createRouteChangeWatcher(root, () => { + syncUiForCurrentPage(); + }); } function init() { - if (!isSupportedPageUrl(root.location && root.location.href ? root.location.href : "")) { - return; - } - if (!root.document || !root.document.body) { root.addEventListener("DOMContentLoaded", init, { once: true }); return; } - ensureStyles(); - ensureButton(); + ensureRouteWatcher(); attachCaptureListener(); injectCaptureHook(); - updateButtonState(); + syncUiForCurrentPage(); } return { buildAutoCopyPayload, buildAutoCopyName, buildPersistRequest, + createRouteChangeWatcher, extractReportId, + getButtonUiState, getLocalApiBaseCandidates, + getSupportedPageType, init, isSupportedPageUrl, isTargetCreateReportRequest,